Adding firsts successful payments. (Still no success proccessing.)

This commit is contained in:
sergiotarxz 2022-12-24 05:17:21 +01:00
parent 46690f9241
commit a13669901f
17 changed files with 1135 additions and 78 deletions

View File

@ -1,6 +1,8 @@
package MyRedland; package MyRedland;
use MyRedland::Lusers; use MyRedland::Lusers;
use MyRedland::Stripe;
use MyRedland::SubscriptionOrders;
use MyRedland::Controller::Metrics; use MyRedland::Controller::Metrics;
use Mojo::Base 'Mojolicious', -signatures; use Mojo::Base 'Mojolicious', -signatures;
@ -38,6 +40,34 @@ sub startup ($self) {
return $user; 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 # Router
my $r = $self->routes; my $r = $self->routes;
@ -46,11 +76,17 @@ sub startup ($self) {
# $r->get('/:post')->to('Page#post'); # $r->get('/:post')->to('Page#post');
$r->get('/perfil')->to('User#profile'); $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/:username/verificacion')->to('User#user_verification');
$r->get('/usuario/avatar')->to('User#get_avatar'); $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->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->get('/logout')->to('User#logout_get');
$r->post('/logout')->to('User#logout'); $r->post('/logout')->to('User#logout');
$r->get('/login')->to('User#login_get'); $r->get('/login')->to('User#login_get');

View File

@ -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;

View File

@ -5,11 +5,13 @@ use v5.34.1;
use strict; use strict;
use warnings; use warnings;
use Mojo::Base 'Mojolicious::Controller';
use Digest::SHA qw/sha512_hex/; use Digest::SHA qw/sha512_hex/;
use Data::Dumper;
use DateTime; use DateTime;
use Mojo::Base 'Mojolicious::Controller';
use Crypt::URandom qw/urandom/; use Crypt::URandom qw/urandom/;
use Crypt::Bcrypt qw/bcrypt/; use Crypt::Bcrypt qw/bcrypt/;
use Capture::Tiny qw/capture/; use Capture::Tiny qw/capture/;
@ -21,10 +23,12 @@ use Mojo::URL;
use Mojo::Util qw/url_escape/; use Mojo::Util qw/url_escape/;
use MyRedland::Mail; use MyRedland::Mail;
use MyRedland::Products;
use MyRedland::Stripe;
use Path::Tiny; use Path::Tiny;
my $PROJECT_ROOT = path(__FILE__)->parent->parent->parent->parent; my $PROJECT_ROOT = path(__FILE__)->parent->parent->parent->parent;
my $UPLOADS = $PROJECT_ROOT->child('uploads'); my $UPLOADS = $PROJECT_ROOT->child('uploads');
$UPLOADS->mkpath; $UPLOADS->mkpath;
my $mt = Mojo::Template->new( auto_escape => 1 ); my $mt = Mojo::Template->new( auto_escape => 1 );
@ -43,29 +47,30 @@ sub login_get {
sub setup_avatar { sub setup_avatar {
my $self = shift; my $self = shift;
my $upload = $self->req->upload('file'); my $upload = $self->req->upload('file');
my $user = $self->current_user; my $user = $self->current_user;
if (!defined $user) { if ( !defined $user ) {
$self->render( $self->render(
status => 401, status => 401,
json => { json => {
status => 401, status => 401,
code => 'NOTLOGGED', code => 'NOTLOGGED',
description => 'No estás loggeado', description => 'No estás loggeado.',
} }
); );
return; return;
} }
if (!defined $user) { if ( !$user->verified ) {
$self->render( $self->render(
status => 401, status => 401,
json => { json => {
status => 401, status => 401,
code => 'NOTVALIDYET', 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; );
} return;
}
if ( !defined $upload ) { if ( !defined $upload ) {
$self->render( $self->render(
status => 400, status => 400,
@ -121,56 +126,57 @@ sub setup_avatar {
'La imagen debe ser cuadrada, debe tener el mismo numero de pixeles de ancho que de alto.' 'La imagen debe ser cuadrada, debe tener el mismo numero de pixeles de ancho que de alto.'
} }
); );
return; return;
} }
my $converted_file = $tempdir->child('converted.png'); my $converted_file = $tempdir->child('converted.png');
( $stdout, $stderr, $error ) = capture { ( $stdout, $stderr, $error ) = capture {
system 'convert', $file, $converted_file; system 'convert', $file, $converted_file;
}; };
if ($error != 0) { if ( $error != 0 ) {
say STDERR $stdout; say STDERR $stdout;
say STDERR $stderr; say STDERR $stderr;
$self->render( $self->render(
status => 500, status => 500,
json => { json => {
status => 500, status => 500,
code => 'SERVERCONVERSIONFAILED', 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; );
} return;
my $sha512 = sha512_hex($converted_file->slurp); }
$user->avatar($sha512); my $sha512 = sha512_hex( $converted_file->slurp );
$user->avatar($sha512);
my $users_dao = MyRedland::Lusers->new( app => $self->app ); my $users_dao = MyRedland::Lusers->new( app => $self->app );
$user = $users_dao->update( user => $user, fields => [qw/avatar/] ); $user = $users_dao->update( user => $user, fields => [qw/avatar/] );
system 'cp', $converted_file, $UPLOADS->child($sha512); system 'cp', $converted_file, $UPLOADS->child($sha512);
$self->render( $self->render(
status => 200, status => 200,
json => { json => {
status => 200, status => 200,
code => 'SUCESS', code => 'SUCESS',
description => 'Your avatar was correctly setup.', description => 'Your avatar was correctly setup.',
} }
); );
} }
sub get_avatar { sub get_avatar {
my $self = shift; my $self = shift;
my $user = $self->current_user; my $user = $self->current_user;
if (!defined $user) { if ( !defined $user ) {
$self->render(status => 401, text => 'Still not logged in.'); $self->render( status => 401, text => 'Still not logged in.' );
return; return;
} }
if (!$user->avatar) { if ( !$user->avatar ) {
$self->render(status => 400, text => 'Avatar still not setup.'); $self->render( status => 400, text => 'Avatar still not setup.' );
return; return;
} }
$self->render( $self->render(
format => 'png', format => 'png',
data => $UPLOADS->child($user->avatar)->slurp data => $UPLOADS->child( $user->avatar )->slurp
); );
} }
@ -479,9 +485,124 @@ sub logout {
return; return;
} }
if ( defined $self->param('yes') ) { 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->res->headers->location('/');
$self->render( text => 'Action succeded', status => 302 ); $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; 1;

View File

@ -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 mail_verification_expiration TIMESTAMP DEFAULT NOW() + interval \'1 day\'',
'ALTER TABLE lusers ADD COLUMN creation_date TIMESTAMP DEFAULT NOW()', '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 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; 1;

View File

@ -6,7 +6,7 @@ use strict;
use warnings; use warnings;
use Moo; use Moo;
use Types::Standard qw/Str Bool InstanceOf/; use Types::Standard qw/Maybe Str Bool InstanceOf/;
use Crypt::Bcrypt qw/bcrypt bcrypt_check/; use Crypt::Bcrypt qw/bcrypt bcrypt_check/;
@ -67,6 +67,11 @@ has avatar => (
isa => Str, isa => Str,
); );
has stripe_customer_id => (
is => 'rw',
isa => Maybe[Str],
);
sub check_password { sub check_password {
my $self = shift; my $self = shift;
my $password = shift; my $password = shift;

View File

@ -21,7 +21,7 @@ my $fpg = DateTime::Format::Pg->new;
my @FIELDS = qw/uuid username email verified my @FIELDS = qw/uuid username email verified
password mail_verification_payload avatar password mail_verification_payload avatar
mail_verification_expiration creation_date mail_verification_expiration creation_date
last_access/; last_access stripe_customer_id/;
has app => ( has app => (
is => 'rw', is => 'rw',
@ -97,7 +97,7 @@ EOF
my $fields = $params{fields}; my $fields = $params{fields};
my %updates; my %updates;
for my $field (@$fields) { 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; $updates{$field} = $user->$field;
next; next;
} }

View File

@ -20,6 +20,7 @@ const my $CURRENT_FILE => __FILE__;
const my $ROOT_PROJECT => path($CURRENT_FILE)->parent->parent->parent; const my $ROOT_PROJECT => path($CURRENT_FILE)->parent->parent->parent;
const my $PUBLIC_DIR => $ROOT_PROJECT->child('public'); const my $PUBLIC_DIR => $ROOT_PROJECT->child('public');
const my $POSTS_DIR => $ROOT_PROJECT->child('content/posts'); const my $POSTS_DIR => $ROOT_PROJECT->child('content/posts');
$POSTS_DIR->mkpath;
const my $BURGUILLOS_LOGO => $PUBLIC_DIR->child('img/burguillos.png'); const my $BURGUILLOS_LOGO => $PUBLIC_DIR->child('img/burguillos.png');
const my $SVG_WIDTH => 1200; const my $SVG_WIDTH => 1200;
const my $SVG_HEIGHT => 627; const my $SVG_HEIGHT => 627;

40
lib/MyRedland/Product.pm Normal file
View File

@ -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;

72
lib/MyRedland/Products.pm Normal file
View File

@ -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;

182
lib/MyRedland/Stripe.pm Normal file
View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -9,6 +9,22 @@ $color-secondary: #8eea6d;
$accent-secondary: #fde68f; $accent-secondary: #fde68f;
$primary-secondary: #590e11; $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 { html {
height: 100%; height: 100%;
} }
@ -112,6 +128,29 @@ body {
font-size: 60px; font-size: 60px;
} }
div.description { 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 { input {
display: block; display: block;
} }
@ -215,8 +254,6 @@ body {
} }
nav { nav {
a.common-user-button { a.common-user-button {
height: 50px;
width: 50px;
float: right; float: right;
display: block; display: block;
margin-top: 3px; margin-top: 3px;
@ -232,6 +269,8 @@ body {
} }
} }
a.profile-button { a.profile-button {
height: 50px;
width: 50px;
padding-left: 0px; padding-left: 0px;
padding-right: 0px; padding-right: 0px;
img { img {

View File

@ -3,5 +3,6 @@
% content_for 'side_menu' => begin % content_for 'side_menu' => begin
<a href="/perfil">Mi perfil</a> <a href="/perfil">Mi perfil</a>
<a href="/perfil/configura-tu-avatar">Avatar</a> <a href="/perfil/configura-tu-avatar">Avatar</a>
<a href="/perfil/opciones-de-subscripcion">Opciones de subscripción</a>
% end % end
<%= content %> <%= content %>

View File

@ -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>

View File

@ -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>

View File

@ -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>