Adding firsts successful payments. (Still no success proccessing.)
This commit is contained in:
parent
46690f9241
commit
a13669901f
|
@ -1,6 +1,8 @@
|
|||
package MyRedland;
|
||||
|
||||
use MyRedland::Lusers;
|
||||
use MyRedland::Stripe;
|
||||
use MyRedland::SubscriptionOrders;
|
||||
use MyRedland::Controller::Metrics;
|
||||
|
||||
use Mojo::Base 'Mojolicious', -signatures;
|
||||
|
@ -38,6 +40,34 @@ sub startup ($self) {
|
|||
return $user;
|
||||
});
|
||||
|
||||
my $subscription_order_dao = MyRedland::SubscriptionOrders->new(app => $self);
|
||||
$self->helper( current_order => sub {
|
||||
my $c = shift;
|
||||
my $maybe_new_order = shift;
|
||||
if (defined $maybe_new_order) {
|
||||
if (!$maybe_new_order->isa('MyRedland::SubscriptionOrder')) {
|
||||
die "Only MyRedland::SubscriptionOrder must be passed to current_order";
|
||||
}
|
||||
$c->session->{subscription_order} = $maybe_new_order->uuid;
|
||||
}
|
||||
my $order_uuid = $c->session->{subscription_order};
|
||||
if (!defined $order_uuid) {
|
||||
return;
|
||||
}
|
||||
return $subscription_order_dao->find_by_uuid( uuid => $order_uuid );
|
||||
});
|
||||
|
||||
my $stripe = MyRedland::Stripe->new( app => $self );
|
||||
$self->helper( current_payment_intent => sub {
|
||||
my $c = shift;
|
||||
my $current_order = $c->current_order;
|
||||
if (!defined $current_order) {
|
||||
return;
|
||||
}
|
||||
my $payment_intent_id = $current_order->payment_intent_id;
|
||||
return $stripe->retrieve_payment_intent( payment_intent_id => $payment_intent_id );
|
||||
});
|
||||
|
||||
# Router
|
||||
my $r = $self->routes;
|
||||
|
||||
|
@ -46,11 +76,17 @@ sub startup ($self) {
|
|||
|
||||
# $r->get('/:post')->to('Page#post');
|
||||
$r->get('/perfil')->to('User#profile');
|
||||
$r->get('/perfil/pago-exitoso')->to('User#payment_success_get');
|
||||
$r->get('/perfil/verifica-el-correo')->to('User#mail_verify');
|
||||
$r->get('/perfil/configura-tu-avatar')->to('User#setup_avatar_get');
|
||||
$r->get('/perfil/opciones-de-subscripcion')->to('User#subscription_options');
|
||||
$r->get('/perfil/subscribirse')->to('User#subscribe_get');
|
||||
$r->get('/usuario/:username/verificacion')->to('User#user_verification');
|
||||
$r->get('/usuario/avatar')->to('User#get_avatar');
|
||||
$r->get('/perfil/verifica-el-correo')->to('User#mail_verify');
|
||||
$r->post('/usuario/actualizar-avatar')->to('User#setup_avatar');
|
||||
$r->get('/perfil/configura-tu-avatar')->to('User#setup_avatar_get');
|
||||
$r->post('/orden-de-subscripcion/renovacion-automatica-api')->to('SubscriptionOrder#renew_auto_api');
|
||||
$r->post('/orden-de-subscripcion/guardar-tarjeta-api')->to('SubscriptionOrder#save_card_api');
|
||||
$r->get('/orden-de-subscripcion/estado')->to('SubscriptionOrder#get_status');
|
||||
$r->get('/logout')->to('User#logout_get');
|
||||
$r->post('/logout')->to('User#logout');
|
||||
$r->get('/login')->to('User#login_get');
|
||||
|
|
|
@ -0,0 +1,138 @@
|
|||
package MyRedland::Controller::SubscriptionOrder;
|
||||
|
||||
use v5.34.1;
|
||||
|
||||
use strict;
|
||||
use warnings;
|
||||
|
||||
use Mojo::Base 'Mojolicious::Controller';
|
||||
|
||||
use MyRedland::Stripe;
|
||||
use MyRedland::SubscriptionOrders;
|
||||
|
||||
sub save_card_api {
|
||||
my $self = shift;
|
||||
my $params = $self->req->json;
|
||||
my $user = $self->current_user;
|
||||
my $so = $self->current_order;
|
||||
my $stripe = MyRedland::Stripe->new( app => $self->app );
|
||||
if ( !defined $user || !defined $so ) {
|
||||
$self->render(
|
||||
json => {
|
||||
error =>
|
||||
'No puedes hacer eso, para hacer esto debes tener un usuario loggeado y una orden de suscripción.'
|
||||
},
|
||||
status => 400
|
||||
);
|
||||
return;
|
||||
}
|
||||
if ( $so->user->uuid ne $user->uuid ) {
|
||||
say STDERR 'Orden de subscripción no coincidente con usuario.';
|
||||
$self->render(
|
||||
json => { error => 'El servidor ha llegado a un estado erroneo.' },
|
||||
status => 500
|
||||
);
|
||||
return;
|
||||
}
|
||||
if ( !defined $params->{new_value} ) {
|
||||
$self->render(
|
||||
json => { error => 'Debes pasar new_value', },
|
||||
status => 400
|
||||
);
|
||||
return;
|
||||
}
|
||||
if ( !$params->{new_value} && $so->renew_auto ) {
|
||||
$self->render(
|
||||
json => {
|
||||
error =>
|
||||
'No puedes deshabilitar el guardado de tarjeta sin deshabilitar la renovación automática'
|
||||
},
|
||||
status => 400
|
||||
);
|
||||
return;
|
||||
}
|
||||
my $new_value = !!$params->{new_value};
|
||||
my $subscription_order_dao =
|
||||
MyRedland::SubscriptionOrders->new( app => $self->app );
|
||||
$so->save_card( $new_value ? 1 : 0 );
|
||||
$so = $subscription_order_dao->update(
|
||||
subscription_order => $so,
|
||||
fields => [qw/save_card/]
|
||||
);
|
||||
$self->render( json => { text => 'Success' } );
|
||||
}
|
||||
|
||||
sub renew_auto_api {
|
||||
my $self = shift;
|
||||
my $params = $self->req->json;
|
||||
my $user = $self->current_user;
|
||||
my $so = $self->current_order;
|
||||
my $stripe = MyRedland::Stripe->new( app => $self->app );
|
||||
if ( !defined $user || !defined $so ) {
|
||||
$self->render(
|
||||
json => {
|
||||
error =>
|
||||
'No puedes hacer eso, para hacer esto debes tener un usuario loggeado y una orden de suscripción.'
|
||||
},
|
||||
status => 400
|
||||
);
|
||||
return;
|
||||
}
|
||||
if ( $so->user->uuid ne $user->uuid ) {
|
||||
say STDERR 'Orden de subscripción no coincidente con usuario.';
|
||||
$self->render(
|
||||
json => { error => 'El servidor ha llegado a un estado erroneo.' },
|
||||
status => 500
|
||||
);
|
||||
return;
|
||||
}
|
||||
if ( !defined $params->{new_value} ) {
|
||||
$self->render(
|
||||
json => { error => 'Debes pasar new_value', },
|
||||
status => 400
|
||||
);
|
||||
return;
|
||||
}
|
||||
my $subscription_order_dao =
|
||||
MyRedland::SubscriptionOrders->new( app => $self->app );
|
||||
my $new_value = $params->{new_value};
|
||||
$so->save_card(1);
|
||||
$so->renew_auto( $new_value ? 1 : 0 );
|
||||
$so = $subscription_order_dao->update(
|
||||
subscription_order => $so,
|
||||
fields => [qw/renew_auto save_card/]
|
||||
);
|
||||
|
||||
$self->render( json => { text => 'Success' } );
|
||||
}
|
||||
|
||||
sub get_status {
|
||||
my $self = shift;
|
||||
my $user = $self->current_user;
|
||||
my $so = $self->current_order;
|
||||
if ( !defined $user || !defined $so ) {
|
||||
$self->render(
|
||||
json => {
|
||||
error =>
|
||||
'No puedes hacer eso, para hacer esto debes tener un usuario loggeado y una orden de suscripción.'
|
||||
},
|
||||
status => 400
|
||||
);
|
||||
return;
|
||||
}
|
||||
if ( $so->user->uuid ne $user->uuid ) {
|
||||
say STDERR 'Orden de subscripción no coincidente con usuario.';
|
||||
$self->render(
|
||||
json => { error => 'El servidor ha llegado a un estado erroneo.' },
|
||||
status => 500
|
||||
);
|
||||
return;
|
||||
}
|
||||
$self->render(
|
||||
json => {
|
||||
save_card => $so->save_card,
|
||||
renew_auto => $so->renew_auto
|
||||
}
|
||||
);
|
||||
}
|
||||
1;
|
|
@ -5,11 +5,13 @@ use v5.34.1;
|
|||
use strict;
|
||||
use warnings;
|
||||
|
||||
use Mojo::Base 'Mojolicious::Controller';
|
||||
|
||||
use Digest::SHA qw/sha512_hex/;
|
||||
use Data::Dumper;
|
||||
|
||||
use DateTime;
|
||||
|
||||
use Mojo::Base 'Mojolicious::Controller';
|
||||
use Crypt::URandom qw/urandom/;
|
||||
use Crypt::Bcrypt qw/bcrypt/;
|
||||
use Capture::Tiny qw/capture/;
|
||||
|
@ -21,6 +23,8 @@ use Mojo::URL;
|
|||
use Mojo::Util qw/url_escape/;
|
||||
|
||||
use MyRedland::Mail;
|
||||
use MyRedland::Products;
|
||||
use MyRedland::Stripe;
|
||||
use Path::Tiny;
|
||||
|
||||
my $PROJECT_ROOT = path(__FILE__)->parent->parent->parent->parent;
|
||||
|
@ -44,24 +48,25 @@ sub setup_avatar {
|
|||
my $self = shift;
|
||||
my $upload = $self->req->upload('file');
|
||||
my $user = $self->current_user;
|
||||
if (!defined $user) {
|
||||
if ( !defined $user ) {
|
||||
$self->render(
|
||||
status => 401,
|
||||
json => {
|
||||
status => 401,
|
||||
code => 'NOTLOGGED',
|
||||
description => 'No estás loggeado',
|
||||
description => 'No estás loggeado.',
|
||||
}
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!defined $user) {
|
||||
if ( !$user->verified ) {
|
||||
$self->render(
|
||||
status => 401,
|
||||
json => {
|
||||
status => 401,
|
||||
code => 'NOTVALIDYET',
|
||||
description => 'Tu usuario no está validado, comprueba tu correo electrónico.',
|
||||
description =>
|
||||
'Tu usuario no está validado, comprueba tu correo electrónico.',
|
||||
}
|
||||
);
|
||||
return;
|
||||
|
@ -127,7 +132,7 @@ sub setup_avatar {
|
|||
( $stdout, $stderr, $error ) = capture {
|
||||
system 'convert', $file, $converted_file;
|
||||
};
|
||||
if ($error != 0) {
|
||||
if ( $error != 0 ) {
|
||||
say STDERR $stdout;
|
||||
say STDERR $stderr;
|
||||
$self->render(
|
||||
|
@ -135,12 +140,13 @@ sub setup_avatar {
|
|||
json => {
|
||||
status => 500,
|
||||
code => 'SERVERCONVERSIONFAILED',
|
||||
description => 'El servidor no fué capaz de convertir el fichero a png, prueba a enviar otro formato.',
|
||||
description =>
|
||||
'El servidor no fué capaz de convertir el fichero a png, prueba a enviar otro formato.',
|
||||
}
|
||||
);
|
||||
return;
|
||||
}
|
||||
my $sha512 = sha512_hex($converted_file->slurp);
|
||||
my $sha512 = sha512_hex( $converted_file->slurp );
|
||||
$user->avatar($sha512);
|
||||
my $users_dao = MyRedland::Lusers->new( app => $self->app );
|
||||
$user = $users_dao->update( user => $user, fields => [qw/avatar/] );
|
||||
|
@ -159,17 +165,17 @@ sub setup_avatar {
|
|||
sub get_avatar {
|
||||
my $self = shift;
|
||||
my $user = $self->current_user;
|
||||
if (!defined $user) {
|
||||
$self->render(status => 401, text => 'Still not logged in.');
|
||||
if ( !defined $user ) {
|
||||
$self->render( status => 401, text => 'Still not logged in.' );
|
||||
return;
|
||||
}
|
||||
if (!$user->avatar) {
|
||||
$self->render(status => 400, text => 'Avatar still not setup.');
|
||||
if ( !$user->avatar ) {
|
||||
$self->render( status => 400, text => 'Avatar still not setup.' );
|
||||
return;
|
||||
}
|
||||
$self->render(
|
||||
format => 'png',
|
||||
data => $UPLOADS->child($user->avatar)->slurp
|
||||
data => $UPLOADS->child( $user->avatar )->slurp
|
||||
);
|
||||
|
||||
}
|
||||
|
@ -479,9 +485,124 @@ sub logout {
|
|||
return;
|
||||
}
|
||||
if ( defined $self->param('yes') ) {
|
||||
delete $self->session->{user_uuid};
|
||||
for my $key ( keys %{ $self->session } ) {
|
||||
|
||||
# We do not want to store any session data after logout because of the security implications.
|
||||
# We use session root keys for things such as storing the current SubscriptionOrder.
|
||||
delete $self->session->{$key};
|
||||
}
|
||||
}
|
||||
$self->res->headers->location('/');
|
||||
$self->render( text => 'Action succeded', status => 302 );
|
||||
}
|
||||
|
||||
sub subscription_options {
|
||||
my $self = shift;
|
||||
my $user = $self->current_user;
|
||||
if ( !defined $user ) {
|
||||
$self->_must_be_logged;
|
||||
return;
|
||||
}
|
||||
if ( !$user->verified ) {
|
||||
$self->_must_be_verified;
|
||||
return;
|
||||
}
|
||||
$self->render;
|
||||
}
|
||||
|
||||
sub subscribe_get {
|
||||
my $self = shift;
|
||||
my $user = $self->current_user;
|
||||
if ( !defined $user ) {
|
||||
$self->_must_be_logged;
|
||||
return;
|
||||
}
|
||||
if ( !$user->verified ) {
|
||||
$self->_must_be_verified;
|
||||
return;
|
||||
}
|
||||
my $product = $self->param('product');
|
||||
if ( !defined $product ) {
|
||||
$self->render( text => 'Debes indicar un producto.', status => 400 );
|
||||
return;
|
||||
}
|
||||
my $products_dao = MyRedland::Products->new;
|
||||
$product = $products_dao->find_by_id( id => $product );
|
||||
if ( !defined $product ) {
|
||||
$self->render( text => 'No se encontró ese producto.', status => 404 );
|
||||
return;
|
||||
}
|
||||
my $stripe = MyRedland::Stripe->new( app => $self->app );
|
||||
if ( !defined $user->stripe_customer_id ) {
|
||||
$user = $stripe->create_customer_for_user($user);
|
||||
}
|
||||
my $payment_intent = $self->current_payment_intent;
|
||||
if ( $self->_check_new_so_conditions_subscription_get($product) ) {
|
||||
say STDERR "Creating new SubscriptionOrder for @{[$user->username]}.";
|
||||
my $subscription_orders_dao =
|
||||
MyRedland::SubscriptionOrders->new( app => $self->app );
|
||||
my $pi = $stripe->create_payment_intent(
|
||||
user => $user,
|
||||
price => $product->price,
|
||||
off_session => 0
|
||||
);
|
||||
my $payment_intent_id = $pi->{id};
|
||||
my $client_secret = $pi->{client_secret};
|
||||
my $subscription_order = $subscription_orders_dao->create(
|
||||
product => $product,
|
||||
user => $user,
|
||||
payment_intent_id => $payment_intent_id,
|
||||
client_secret => $client_secret,
|
||||
renew_auto => 1,
|
||||
save_card => 1,
|
||||
paid => 0,
|
||||
to_pay => $product->price
|
||||
);
|
||||
$self->current_order($subscription_order);
|
||||
}
|
||||
my $nonce = unpack 'H*', urandom(15);
|
||||
$self->res->headers->content_security_policy(
|
||||
"default-src 'self' 'nonce-$nonce'; connect-src 'self' https://api.stripe.com https://maps.googleapis.com; frame-src https://js.stripe.com https://hooks.stripe.com; script-src 'self' 'nonce-$nonce' https://js.stripe.com https://maps.googleapis.com"
|
||||
);
|
||||
$self->render(
|
||||
product => $product,
|
||||
subscription_order => $self->current_order,
|
||||
nonce => $nonce,
|
||||
config => $self->app->config
|
||||
);
|
||||
}
|
||||
|
||||
sub _check_new_so_conditions_subscription_get {
|
||||
my $self = shift;
|
||||
my $product = shift;
|
||||
my $payment_intent = $self->current_payment_intent;
|
||||
my $user = $self->current_user;
|
||||
return
|
||||
!defined $self->current_order
|
||||
|| $product->id ne $self->current_order->product->id
|
||||
|| $user->uuid ne $self->current_order->user->uuid
|
||||
|| $payment_intent->{status} eq 'succeeded';
|
||||
}
|
||||
|
||||
sub payment_success_get {
|
||||
my $self = shift;
|
||||
my $current_order = $self->current_order;
|
||||
if ( !defined $current_order ) {
|
||||
$self->render(
|
||||
text =>
|
||||
'No hay ninguna orden guardada en tu navegador, esto no quiere decir que tu orden no exista, contacta con contact@owlcode.tech en caso de duda.',
|
||||
status => 400
|
||||
);
|
||||
return;
|
||||
}
|
||||
my $payment_intent = $self->current_payment_intent;
|
||||
if (!defined $payment_intent) {
|
||||
$self->render(
|
||||
text => 'No hay orden de pago, esto es un error del servidor.',
|
||||
status => 500,
|
||||
);
|
||||
return;
|
||||
}
|
||||
$self->render( so => $current_order, pi => $payment_intent );
|
||||
}
|
||||
1;
|
||||
|
|
|
@ -38,6 +38,33 @@ sub MIGRATIONS {
|
|||
'ALTER TABLE lusers ADD COLUMN mail_verification_expiration TIMESTAMP DEFAULT NOW() + interval \'1 day\'',
|
||||
'ALTER TABLE lusers ADD COLUMN creation_date TIMESTAMP DEFAULT NOW()',
|
||||
'ALTER TABLE lusers ADD COLUMN last_access TIMESTAMP DEFAULT NOW()',
|
||||
'ALTER TABLE lusers ADD COLUMN stripe_customer_id TEXT DEFAULT NULL',
|
||||
'CREATE TABLE subscription_orders (
|
||||
uuid UUID DEFAULT uuid_generate_v4() PRIMARY KEY,
|
||||
product_id TEXT NOT NULL,
|
||||
user_uuid UUID NOT NULL,
|
||||
payment_intent_id TEXT NOT NULL,
|
||||
client_secret TEXT NOT NULL,
|
||||
renew_auto BOOLEAN NOT NULL,
|
||||
paid BOOLEAN DEFAULT false,
|
||||
create_date timestamp DEFAULT NOW(),
|
||||
to_pay INTEGER NOT NULL,
|
||||
FOREIGN KEY (user_uuid) REFERENCES lusers(uuid)
|
||||
)',
|
||||
'CREATE TABLE public_servers (
|
||||
uuid UUID DEFAULT uuid_generate_v4() PRIMARY KEY,
|
||||
identifier TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL
|
||||
)',
|
||||
'INSERT INTO public_servers (name, identifier) VALUES (\'Principal Server\', \'server1\')',
|
||||
'CREATE TABLE subscriptions (
|
||||
public_server_uuid UUID NOT NULL,
|
||||
user_uuid UUID NOT NULL,
|
||||
valid_until timestamp NOT NULL,
|
||||
renew_auto BOOLEAN NOT NULL,
|
||||
PRIMARY KEY (public_server_uuid, user_uuid)
|
||||
)',
|
||||
'ALTER TABLE subscription_orders ADD COLUMN save_card BOOLEAN DEFAULT true',
|
||||
);
|
||||
}
|
||||
1;
|
||||
|
|
|
@ -6,7 +6,7 @@ use strict;
|
|||
use warnings;
|
||||
|
||||
use Moo;
|
||||
use Types::Standard qw/Str Bool InstanceOf/;
|
||||
use Types::Standard qw/Maybe Str Bool InstanceOf/;
|
||||
|
||||
use Crypt::Bcrypt qw/bcrypt bcrypt_check/;
|
||||
|
||||
|
@ -67,6 +67,11 @@ has avatar => (
|
|||
isa => Str,
|
||||
);
|
||||
|
||||
has stripe_customer_id => (
|
||||
is => 'rw',
|
||||
isa => Maybe[Str],
|
||||
);
|
||||
|
||||
sub check_password {
|
||||
my $self = shift;
|
||||
my $password = shift;
|
||||
|
|
|
@ -21,7 +21,7 @@ my $fpg = DateTime::Format::Pg->new;
|
|||
my @FIELDS = qw/uuid username email verified
|
||||
password mail_verification_payload avatar
|
||||
mail_verification_expiration creation_date
|
||||
last_access/;
|
||||
last_access stripe_customer_id/;
|
||||
|
||||
has app => (
|
||||
is => 'rw',
|
||||
|
@ -97,7 +97,7 @@ EOF
|
|||
my $fields = $params{fields};
|
||||
my %updates;
|
||||
for my $field (@$fields) {
|
||||
if ( any { $field eq $_ } qw/verified email password mail_verification_expiration mail_verification_payload last_access avatar/) {
|
||||
if ( any { $field eq $_ } qw/verified email password mail_verification_expiration mail_verification_payload last_access avatar stripe_customer_id/) {
|
||||
$updates{$field} = $user->$field;
|
||||
next;
|
||||
}
|
||||
|
|
|
@ -20,6 +20,7 @@ const my $CURRENT_FILE => __FILE__;
|
|||
const my $ROOT_PROJECT => path($CURRENT_FILE)->parent->parent->parent;
|
||||
const my $PUBLIC_DIR => $ROOT_PROJECT->child('public');
|
||||
const my $POSTS_DIR => $ROOT_PROJECT->child('content/posts');
|
||||
$POSTS_DIR->mkpath;
|
||||
const my $BURGUILLOS_LOGO => $PUBLIC_DIR->child('img/burguillos.png');
|
||||
const my $SVG_WIDTH => 1200;
|
||||
const my $SVG_HEIGHT => 627;
|
||||
|
|
|
@ -0,0 +1,40 @@
|
|||
package MyRedland::Product;
|
||||
|
||||
use v5.34.1;
|
||||
|
||||
use strict;
|
||||
use warnings;
|
||||
|
||||
use Moo;
|
||||
use Types::Standard qw/Str Int/;
|
||||
|
||||
has name => (
|
||||
is => 'ro',
|
||||
isa => Str,
|
||||
required => 1,
|
||||
);
|
||||
|
||||
has id => (
|
||||
is => 'ro',
|
||||
isa => Str,
|
||||
required => 1,
|
||||
);
|
||||
|
||||
has description => (
|
||||
is => 'ro',
|
||||
isa => Str,
|
||||
required => 1,
|
||||
);
|
||||
|
||||
has price => (
|
||||
is => 'ro',
|
||||
isa => Int,
|
||||
required => 1,
|
||||
);
|
||||
|
||||
has period => (
|
||||
is => 'ro',
|
||||
isa => Str,
|
||||
required => 1,
|
||||
);
|
||||
1;
|
|
@ -0,0 +1,72 @@
|
|||
package MyRedland::Products;
|
||||
|
||||
use v5.34.1;
|
||||
|
||||
use strict;
|
||||
use warnings;
|
||||
|
||||
use utf8;
|
||||
|
||||
use MyRedland::Product;
|
||||
|
||||
use Moo;
|
||||
use Types::Standard qw/Str Int/;
|
||||
use Params::ValidationCompiler qw/validation_for/;
|
||||
|
||||
has all => (
|
||||
is => 'lazy'
|
||||
);
|
||||
|
||||
has _all_by_id => (
|
||||
is => 'lazy'
|
||||
);
|
||||
|
||||
sub _build_all {
|
||||
my $self = shift;
|
||||
return [
|
||||
MyRedland::Product->new(
|
||||
id => 'MONTH',
|
||||
name => 'Pago mensual',
|
||||
description => 'Paga mes a mes tu subscripción al servidor principal.',
|
||||
price => 300,
|
||||
period => 'mes',
|
||||
),
|
||||
MyRedland::Product->new(
|
||||
id => 'PAIRMONTHLY',
|
||||
name => 'Pago mensual en pareja',
|
||||
description => 'Paga mes a mes tu suscripción y la de otra persona, os ahorráis 1€/mes entre los dos.',
|
||||
price => 500,
|
||||
period => 'mes',
|
||||
),
|
||||
MyRedland::Product->new(
|
||||
id => 'YEAR',
|
||||
name => 'Pago anual',
|
||||
description => 'Paga anualmente tu subscripción al servidor principal, pagas 1.92€/m, 1.08€/m menos, te ahorras 13€/año.',
|
||||
price => 2300,
|
||||
period => 'año',
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
sub _build__all_by_id {
|
||||
my $self = shift;
|
||||
my $all = $self->all;
|
||||
return {
|
||||
(map { $_->id => $_ } @$all),
|
||||
};
|
||||
}
|
||||
|
||||
{
|
||||
my $validator = validation_for(
|
||||
params => {
|
||||
id => { type => Str },
|
||||
}
|
||||
);
|
||||
sub find_by_id {
|
||||
my $self = shift;
|
||||
my %params = $validator->(@_);
|
||||
my $id = $params{id};
|
||||
return $self->_all_by_id->{$id};
|
||||
}
|
||||
}
|
||||
1;
|
|
@ -0,0 +1,182 @@
|
|||
package MyRedland::Stripe;
|
||||
|
||||
use v5.34.1;
|
||||
|
||||
use strict;
|
||||
use warnings;
|
||||
|
||||
use MyRedland::DB;
|
||||
use MyRedland::Lusers;
|
||||
use Mojo::UserAgent;
|
||||
|
||||
use Moo;
|
||||
use Params::ValidationCompiler qw/validation_for/;
|
||||
use Types::Standard qw/Str InstanceOf HashRef Int Bool/;
|
||||
use MIME::Base64;
|
||||
use Data::Dumper;
|
||||
use Mojo::Util qw/url_escape/;
|
||||
|
||||
my $ua = Mojo::UserAgent->new;
|
||||
|
||||
has app => (
|
||||
is => 'ro',
|
||||
isa => InstanceOf ['Mojolicious'],
|
||||
);
|
||||
|
||||
has _dbh => ( is => 'lazy', );
|
||||
|
||||
has _stripe_secret => ( is => 'lazy', );
|
||||
|
||||
has _users_dao => ( is => 'lazy', );
|
||||
|
||||
sub _build__dbh {
|
||||
my $self = shift;
|
||||
return MyRedland::DB->new( app => $self->app );
|
||||
}
|
||||
|
||||
sub _build__stripe_secret {
|
||||
my $self = shift;
|
||||
return $self->app->config->{stripe_secret};
|
||||
}
|
||||
|
||||
sub _build__users_dao {
|
||||
my $self = shift;
|
||||
return MyRedland::Lusers->new( app => $self->app );
|
||||
}
|
||||
|
||||
{
|
||||
my $validator = validation_for(
|
||||
params => {
|
||||
method => { type => Str },
|
||||
url => { type => Str },
|
||||
form => { type => HashRef, optional => 1 },
|
||||
headers => { type => HashRef, optional => 1 },
|
||||
}
|
||||
);
|
||||
|
||||
sub _make_request {
|
||||
my $self = shift;
|
||||
my %params = $validator->(@_);
|
||||
my $method = $params{method};
|
||||
my $url = $params{url};
|
||||
my $headers = $params{headers} // {};
|
||||
$headers->{Authorization} =
|
||||
'Basic ' . encode_base64( $self->_stripe_secret . ':', '' );
|
||||
my $form = $params{form};
|
||||
my $tx = $ua->build_tx( $method, $url, $headers,
|
||||
( ( defined $form ) ? ( form => $form ) : () ) );
|
||||
return $ua->start($tx);
|
||||
}
|
||||
}
|
||||
|
||||
sub create_customer_for_user {
|
||||
my $self = shift;
|
||||
my $user = shift;
|
||||
if ( defined $user->stripe_customer_id ) {
|
||||
return $user;
|
||||
}
|
||||
my $response = $self->_make_request(
|
||||
method => 'POST',
|
||||
url => 'https://api.stripe.com/v1/customers'
|
||||
)->result->json;
|
||||
|
||||
$user->stripe_customer_id( $response->{id} );
|
||||
$user = $self->_users_dao->update(
|
||||
user => $user,
|
||||
fields => [qw/stripe_customer_id/]
|
||||
);
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
{
|
||||
my $validator = validation_for(
|
||||
params => {
|
||||
user => {
|
||||
type => InstanceOf ['MyRedland::Luser'],
|
||||
},
|
||||
price => {
|
||||
type => Int,
|
||||
},
|
||||
off_session => {
|
||||
type => Bool
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
sub create_payment_intent {
|
||||
my $self = shift;
|
||||
my %params = $validator->(@_);
|
||||
my ( $user, $price, $off_session ) =
|
||||
@params{qw/user price off_session/};
|
||||
my $response = $self->_make_request(
|
||||
method => 'POST',
|
||||
url => 'https://api.stripe.com/v1/payment_intents',
|
||||
form => {
|
||||
currency => 'eur',
|
||||
amount => $price,
|
||||
'payment_method_types[]' => 'card',
|
||||
customer => $user->stripe_customer_id,
|
||||
( ($off_session) ? ( off_session => $off_session ) : () ),
|
||||
}
|
||||
)->result->json;
|
||||
if ( defined $response->{error} ) {
|
||||
die Data::Dumper::Dumper $response->{error};
|
||||
}
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
my $validator = validation_for(
|
||||
params => {
|
||||
payment_intent_id => {
|
||||
type => Str
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
sub retrieve_payment_intent {
|
||||
my $self = shift;
|
||||
my %params = $validator->(@_);
|
||||
my ($payment_intent_id) = $params{'payment_intent_id'};
|
||||
my $response = $self->_make_request(
|
||||
method => 'GET',
|
||||
url => 'https://api.stripe.com/v1/payment_intents/'.url_escape($payment_intent_id),
|
||||
)->result->json;
|
||||
if ( defined $response->{error} ) {
|
||||
die Data::Dumper::Dumper $response->{error};
|
||||
}
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
my $validator = validation_for(
|
||||
params => {
|
||||
payment_intent_id => {
|
||||
type => Str
|
||||
},
|
||||
updates => {
|
||||
type => HashRef
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
sub update_payment_intent {
|
||||
my $self = shift;
|
||||
my %params = $validator->(@_);
|
||||
my ($payment_intent_id, $updates) = @params{qw/payment_intent_id updates/};
|
||||
my $response = $self->_make_request(
|
||||
method => 'POST',
|
||||
url => 'https://api.stripe.com/v1/payment_intents/'.url_escape($payment_intent_id),
|
||||
form => $updates,
|
||||
)->result->json;
|
||||
print Data::Dumper::Dumper $response;
|
||||
if ( defined $response->{error} ) {
|
||||
die Data::Dumper::Dumper $response->{error};
|
||||
}
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
1;
|
|
@ -0,0 +1,56 @@
|
|||
package MyRedland::SubscriptionOrder;
|
||||
|
||||
use v5.34.1;
|
||||
|
||||
use strict;
|
||||
use warnings;
|
||||
|
||||
use Moo;
|
||||
use Types::Standard qw/Str Bool InstanceOf Int/;
|
||||
|
||||
has uuid => (
|
||||
is => 'ro',
|
||||
isa => Str,
|
||||
);
|
||||
has product => (
|
||||
is => 'ro',
|
||||
isa => InstanceOf['MyRedland::Product'],
|
||||
);
|
||||
has user => (
|
||||
is => 'ro',
|
||||
isa => InstanceOf['MyRedland::Luser'],
|
||||
);
|
||||
has payment_intent_id => (
|
||||
is => 'ro',
|
||||
isa => Str,
|
||||
);
|
||||
has client_secret => (
|
||||
is => 'ro',
|
||||
isa => Str,
|
||||
);
|
||||
|
||||
has renew_auto => (
|
||||
is => 'rw',
|
||||
isa => Bool,
|
||||
);
|
||||
|
||||
has paid => (
|
||||
is => 'rw',
|
||||
isa => Bool,
|
||||
);
|
||||
|
||||
has to_pay => (
|
||||
is => 'ro',
|
||||
isa => Int,
|
||||
);
|
||||
|
||||
has create_date => (
|
||||
is => 'ro',
|
||||
isa => InstanceOf['DateTime'],
|
||||
);
|
||||
|
||||
has save_card => (
|
||||
is => 'rw',
|
||||
isa => Bool,
|
||||
);
|
||||
1;
|
|
@ -0,0 +1,146 @@
|
|||
package MyRedland::SubscriptionOrders;
|
||||
|
||||
use v5.34.1;
|
||||
|
||||
use strict;
|
||||
use warnings;
|
||||
|
||||
use Moo;
|
||||
use Types::Standard qw/Str Bool InstanceOf ArrayRef Int/;
|
||||
use Params::ValidationCompiler qw/validation_for/;
|
||||
use DateTime::Format::Pg;
|
||||
use Data::Dumper;
|
||||
use List::AllUtils qw/any/;
|
||||
|
||||
use MyRedland::DB;
|
||||
use MyRedland::Lusers;
|
||||
use MyRedland::Products;
|
||||
use MyRedland::SubscriptionOrder;
|
||||
has app => (
|
||||
is => 'ro',
|
||||
isa => InstanceOf['Mojolicious'],
|
||||
);
|
||||
|
||||
my $fpg = DateTime::Format::Pg->new;
|
||||
|
||||
my @FIELDS = qw/uuid product_id user_uuid payment_intent_id client_secret renew_auto paid create_date to_pay save_card/;
|
||||
|
||||
has dbh => ( is => 'lazy', );
|
||||
|
||||
sub _build_dbh {
|
||||
my $self = shift;
|
||||
return MyRedland::DB->connect( $self->app );
|
||||
}
|
||||
|
||||
{
|
||||
my $validator = validation_for(
|
||||
params => {
|
||||
product => { type => InstanceOf['MyRedland::Product'] },
|
||||
user => { type => InstanceOf['MyRedland::Luser'] },
|
||||
payment_intent_id => { type => Str },
|
||||
client_secret => { type => Str },
|
||||
renew_auto => { type => Bool },
|
||||
save_card => { type => Bool },
|
||||
paid => { type => Bool },
|
||||
to_pay => { type => Int },
|
||||
}
|
||||
);
|
||||
|
||||
sub create {
|
||||
my $self = shift;
|
||||
my %params = $validator->(@_);
|
||||
my $dbh = $self->dbh;
|
||||
my ($product, $user, $payment_intent_id, $client_secret, $renew_auto, $paid, $to_pay, $save_card) = @params{qw/product user payment_intent_id client_secret
|
||||
renew_auto paid to_pay save_card/};
|
||||
my $returning_hash = $dbh->selectrow_hashref(
|
||||
<<"EOF", undef, $product->id, $user->uuid, $client_secret, $renew_auto, $paid, $to_pay, $payment_intent_id, $save_card);
|
||||
INSERT INTO subscription_orders
|
||||
(product_id, user_uuid, client_secret, renew_auto,
|
||||
paid, to_pay, payment_intent_id, save_card)
|
||||
VALUES
|
||||
(?, ?, ?, ?, ?, ?, ?, ?)
|
||||
RETURNING @{[join ', ', @FIELDS]};
|
||||
EOF
|
||||
return $self->_convert_hash_to_object($returning_hash);
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
sub _convert_hash_to_object {
|
||||
my $self = shift;
|
||||
my $hash = shift;
|
||||
my $users_dao = MyRedland::Lusers->new( app => $self->app );
|
||||
my $products_dao = MyRedland::Products->new;
|
||||
my $product_id = delete $hash->{product_id};
|
||||
my $user_uuid = delete $hash->{user_uuid};
|
||||
my $create_date = delete $hash->{create_date};
|
||||
$hash->{create_date} = $fpg->parse_datetime($create_date);
|
||||
if (!defined $product_id) {
|
||||
# This should not happen.
|
||||
die 'No product.';
|
||||
}
|
||||
if (!defined $user_uuid) {
|
||||
# This should not happen.
|
||||
die 'No user';
|
||||
}
|
||||
$hash->{user} = $users_dao->find_by_uuid(uuid => $user_uuid);
|
||||
$hash->{product} = $products_dao->find_by_id(id => $product_id);
|
||||
return MyRedland::SubscriptionOrder->new(%$hash);
|
||||
}
|
||||
|
||||
{
|
||||
my $validator = validation_for(
|
||||
params => {
|
||||
subscription_order => { type => InstanceOf ['MyRedland::SubscriptionOrder'] },
|
||||
fields => { type => ArrayRef [Str] },
|
||||
}
|
||||
);
|
||||
|
||||
sub update {
|
||||
my $self = shift;
|
||||
my %params = $validator->(@_);
|
||||
my $subscription_order = $params{subscription_order};
|
||||
my $fields = $params{fields};
|
||||
my %updates;
|
||||
for my $field (@$fields) {
|
||||
if ( any { $field eq $_ } qw/renew_auto paid save_card/ ) {
|
||||
$updates{$field} = $subscription_order->$field;
|
||||
next;
|
||||
}
|
||||
die "No such field $field.";
|
||||
}
|
||||
my $query = <<"EOF";
|
||||
UPDATE subscription_orders
|
||||
SET @{[
|
||||
join ', ', map { "$_ = ?" } @$fields
|
||||
]} WHERE uuid = ?
|
||||
RETURNING @{[join ', ', @FIELDS]};
|
||||
EOF
|
||||
my $dbh = $self->dbh;
|
||||
my $hash = $dbh->selectrow_hashref($query, undef, @updates{@$fields}, $subscription_order->uuid);
|
||||
return $self->_convert_hash_to_object($hash);
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
my $validator = validation_for(
|
||||
params => {
|
||||
uuid => { type => Str },
|
||||
}
|
||||
);
|
||||
|
||||
sub find_by_uuid {
|
||||
my $self = shift;
|
||||
my %params = $validator->(@_);
|
||||
my $uuid = $params{uuid};
|
||||
my $dbh = $self->dbh;
|
||||
my $hash = $dbh->selectrow_hashref( <<"EOF", undef, $uuid );
|
||||
SELECT @{[join ', ', @FIELDS]} FROM subscription_orders where uuid = ?;
|
||||
EOF
|
||||
if ( !defined $hash ) {
|
||||
return;
|
||||
}
|
||||
return $self->_convert_hash_to_object($hash);
|
||||
}
|
||||
}
|
||||
1;
|
|
@ -9,6 +9,22 @@ $color-secondary: #8eea6d;
|
|||
$accent-secondary: #fde68f;
|
||||
$primary-secondary: #590e11;
|
||||
|
||||
#payment-form {
|
||||
background: $color_div;
|
||||
color: $background_div;
|
||||
padding: 10px;
|
||||
div.submit-div {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
button {
|
||||
background: $background_div;
|
||||
font-size: 19px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
html {
|
||||
height: 100%;
|
||||
}
|
||||
|
@ -112,6 +128,29 @@ body {
|
|||
font-size: 60px;
|
||||
}
|
||||
div.description {
|
||||
div.subscription-options {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
div.product {
|
||||
width: 44%;
|
||||
margin-right: 3%;
|
||||
color: $background_div;
|
||||
background: $color_div;
|
||||
padding: 30px;
|
||||
padding-top: 10px;
|
||||
|
||||
p {
|
||||
font-size: 20px;
|
||||
}
|
||||
a.subscribe-button {
|
||||
padding: 10px;
|
||||
background: $background_div;
|
||||
color: $color_div;
|
||||
border-radius: 3px;
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
input {
|
||||
display: block;
|
||||
}
|
||||
|
@ -215,8 +254,6 @@ body {
|
|||
}
|
||||
nav {
|
||||
a.common-user-button {
|
||||
height: 50px;
|
||||
width: 50px;
|
||||
float: right;
|
||||
display: block;
|
||||
margin-top: 3px;
|
||||
|
@ -232,6 +269,8 @@ body {
|
|||
}
|
||||
}
|
||||
a.profile-button {
|
||||
height: 50px;
|
||||
width: 50px;
|
||||
padding-left: 0px;
|
||||
padding-right: 0px;
|
||||
img {
|
||||
|
|
|
@ -3,5 +3,6 @@
|
|||
% content_for 'side_menu' => begin
|
||||
<a href="/perfil">Mi perfil</a>
|
||||
<a href="/perfil/configura-tu-avatar">Avatar</a>
|
||||
<a href="/perfil/opciones-de-subscripcion">Opciones de subscripción</a>
|
||||
% end
|
||||
<%= content %>
|
||||
|
|
|
@ -0,0 +1,9 @@
|
|||
% title 'Pago exitoso - Redland Official';
|
||||
% layout 'side_menu';
|
||||
% my $so = stash 'so';
|
||||
% my $pi = stash 'pi';
|
||||
<div class="description">
|
||||
<h2>Pago exitoso.</h2>
|
||||
|
||||
<p>Te has subscrito con exito en la modalidad "<%=$so->product->name%>" por la cantidad de <%=$so->to_pay/100%>€ al <%=$so->product->period%>.</p>
|
||||
</div>
|
|
@ -0,0 +1,163 @@
|
|||
% use JSON qw/encode_json/;
|
||||
% title 'Suscribete - Redland Official';
|
||||
% layout 'side_menu';
|
||||
% my $product = stash 'product';
|
||||
% my $so = stash 'subscription_order';
|
||||
% my $current_payment_intent = $self->current_payment_intent;
|
||||
|
||||
<div class="description">
|
||||
<h2>Ultimando tu subscripción.</h2>
|
||||
|
||||
<input type="hidden" id="publishable-secret" value="<%=$config->{stripe_public}%>"/>
|
||||
<input type="hidden" id="so-uuid" value="<%=$so->uuid%>"/>
|
||||
|
||||
<p>Estás suscribiendote en la modalidad "<%=$product->name%>"</p>
|
||||
|
||||
<p>Vas a pagar <%=$so->to_pay/100%>€ al <%=$product->period%>.</p>
|
||||
|
||||
<form id="payment-form">
|
||||
<input type="checkbox" id="save-card"
|
||||
<%=$so->save_card ? 'checked' : ''%> <%=$so->renew_auto ? 'disabled' : ''%>/>
|
||||
<label>Guardar la tarjeta para futuros pagos.</label>
|
||||
<input type="checkbox" id="renew-subscription" value="Renovar la subscripción automáticamente."
|
||||
<%=$so->renew_auto ? 'checked': ''%>/>
|
||||
<label>Renovar la subscripción automáticamente.</label>
|
||||
|
||||
<div id="card-element">
|
||||
</div>
|
||||
<div class="submit-div">
|
||||
<button id="submit">Enviar pago</button>
|
||||
</div>
|
||||
<div id="error-message">
|
||||
</div>
|
||||
</form>
|
||||
|
||||
</div>
|
||||
<script src="https://js.stripe.com/v3/"></script>
|
||||
<script nonce="<%=$nonce%>">
|
||||
const publishable_secret_element = document.querySelector('#publishable-secret');
|
||||
const stripe = Stripe(publishable_secret_element.value);
|
||||
const options = JSON.parse(<%== encode_json(encode_json(
|
||||
{
|
||||
clientSecret => $so->client_secret,
|
||||
appearance => {
|
||||
theme => 'night',
|
||||
variables => {
|
||||
colorBackground => '#ffffff',
|
||||
colorText => '#30313d',
|
||||
colorDanger => '#df1b41',
|
||||
}
|
||||
}
|
||||
}
|
||||
));%>);
|
||||
const clientSecret = options.clientSecret;
|
||||
const appearance = options.appearance;
|
||||
const renew_subscription_element = document.querySelector('#renew-subscription');
|
||||
const style = {
|
||||
base: {
|
||||
background: '#191326',
|
||||
color: "#DC143C",
|
||||
fontFamily: 'Arial, sans-serif',
|
||||
fontSmoothing: "antialiased",
|
||||
fontSize: "25px",
|
||||
"::placeholder": {
|
||||
color: "#DC143C",
|
||||
background: '#191326',
|
||||
}
|
||||
},
|
||||
invalid: {
|
||||
fontFamily: 'Arial, sans-serif',
|
||||
color: "#fa755a",
|
||||
iconColor: "#fa755a"
|
||||
}
|
||||
};
|
||||
const save_card_element = document.querySelector('#save-card');
|
||||
|
||||
const form = document.querySelector('#payment-form');
|
||||
|
||||
async function update_checkboxes() {
|
||||
const response = await fetch('/orden-de-subscripcion/estado', {
|
||||
method: 'GET',
|
||||
});
|
||||
const text = await response.text();
|
||||
const body = JSON.parse(text);
|
||||
save_card_element.checked = !!body.save_card;
|
||||
renew_subscription_element.checked = !!body.renew_auto;
|
||||
save_card_element.disabled = !!body.renew_auto;
|
||||
renew_subscription_element.disabled = false;
|
||||
}
|
||||
|
||||
function payWithCard(stripe, card, client_secret) {
|
||||
update_checkboxes().then(() => {
|
||||
const data = {
|
||||
payment_method: {
|
||||
card: card
|
||||
}
|
||||
};
|
||||
data.setup_future_usage = 'off_session';
|
||||
stripe.confirmCardPayment(clientSecret, data)
|
||||
.then(function(result) {
|
||||
if (result.error) {
|
||||
// Show error to your customer
|
||||
showError(result.error.message);
|
||||
} else {
|
||||
window.location = '/perfil/pago-exitoso';
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function showError(error) {
|
||||
const error_msg = document.querySelector("#error-message");
|
||||
error_msg.textContent = error;
|
||||
setTimeout(function() {
|
||||
error_msg.textContent = "";
|
||||
}, 4000);
|
||||
}
|
||||
|
||||
update_checkboxes();
|
||||
|
||||
save_card_element.addEventListener('change', () => {
|
||||
save_card_element.disabled = true;
|
||||
renew_subscription_element.disabled = true;
|
||||
fetch('/orden-de-subscripcion/guardar-tarjeta-api', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({new_value: save_card_element.checked}),
|
||||
}).then((response) => response.text()).then((text) => {
|
||||
const body = JSON.parse(text);
|
||||
if (body['error'] != null) {
|
||||
alert(body['error']);
|
||||
}
|
||||
update_checkboxes();
|
||||
});
|
||||
});
|
||||
|
||||
renew_subscription_element.addEventListener('change', () => {
|
||||
save_card_element.disabled = true;
|
||||
renew_subscription_element.disabled = true;
|
||||
fetch('/orden-de-subscripcion/renovacion-automatica-api', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({new_value: renew_subscription_element.checked}),
|
||||
}).then((response) => response.text()).then( (text) => {
|
||||
const body = JSON.parse(text);
|
||||
if (body['error'] != null) {
|
||||
alert(body['error']);
|
||||
}
|
||||
update_checkboxes();
|
||||
});
|
||||
});
|
||||
|
||||
const elements = stripe.elements({clientSecret, appearance});
|
||||
|
||||
// Create and mount the Payment Element
|
||||
const card = elements.create('card', { style, hideIcon: true });
|
||||
card.mount('#card-element');
|
||||
card.on("change", function (event) {
|
||||
document.querySelector("#submit").disabled = event.empty;
|
||||
document.querySelector("#error-message").textContent = event.error ? event.error.message : "";
|
||||
});
|
||||
form.addEventListener('submit', (event) => {
|
||||
event.preventDefault();
|
||||
payWithCard(stripe, card, options.clientSecret);
|
||||
});
|
||||
</script>
|
|
@ -0,0 +1,21 @@
|
|||
% use MyRedland::Products;
|
||||
% use Mojo::Util qw/xml_escape/;
|
||||
%
|
||||
% title 'Opciones de subscripción - Redland Official';
|
||||
% layout 'side_menu';
|
||||
% my $products_dao = MyRedland::Products->new;
|
||||
% my $products = $products_dao->all;
|
||||
|
||||
<div class="description">
|
||||
<h2>Opciones de subscripción.</h2>
|
||||
<div class="subscription-options">
|
||||
% for my $product (@$products) {
|
||||
<div class="product">
|
||||
<p class="product-price"><%=$product->price/100%>€</p>
|
||||
<p><%=$product->name%></p>
|
||||
<p><%=$product->description%></p>
|
||||
<a class="subscribe-button" href="/perfil/subscribirse?product=<%=$product->id%>">Subscribirse.</a>
|
||||
</div>
|
||||
% }
|
||||
</div>
|
||||
</div>
|
Loading…
Reference in New Issue