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;
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');

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 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,10 +23,12 @@ 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;
my $UPLOADS = $PROJECT_ROOT->child('uploads');
my $UPLOADS = $PROJECT_ROOT->child('uploads');
$UPLOADS->mkpath;
my $mt = Mojo::Template->new( auto_escape => 1 );
@ -43,29 +47,30 @@ sub login_get {
sub setup_avatar {
my $self = shift;
my $upload = $self->req->upload('file');
my $user = $self->current_user;
if (!defined $user) {
$self->render(
status => 401,
json => {
status => 401,
code => 'NOTLOGGED',
description => 'No estás loggeado',
}
);
return;
}
if (!defined $user) {
$self->render(
status => 401,
json => {
status => 401,
code => 'NOTVALIDYET',
description => 'Tu usuario no está validado, comprueba tu correo electrónico.',
}
);
return;
}
my $user = $self->current_user;
if ( !defined $user ) {
$self->render(
status => 401,
json => {
status => 401,
code => 'NOTLOGGED',
description => 'No estás loggeado.',
}
);
return;
}
if ( !$user->verified ) {
$self->render(
status => 401,
json => {
status => 401,
code => 'NOTVALIDYET',
description =>
'Tu usuario no está validado, comprueba tu correo electrónico.',
}
);
return;
}
if ( !defined $upload ) {
$self->render(
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.'
}
);
return;
return;
}
my $converted_file = $tempdir->child('converted.png');
( $stdout, $stderr, $error ) = capture {
system 'convert', $file, $converted_file;
};
if ($error != 0) {
say STDERR $stdout;
say STDERR $stderr;
$self->render(
status => 500,
json => {
status => 500,
code => 'SERVERCONVERSIONFAILED',
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);
$user->avatar($sha512);
my $converted_file = $tempdir->child('converted.png');
( $stdout, $stderr, $error ) = capture {
system 'convert', $file, $converted_file;
};
if ( $error != 0 ) {
say STDERR $stdout;
say STDERR $stderr;
$self->render(
status => 500,
json => {
status => 500,
code => 'SERVERCONVERSIONFAILED',
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 );
$user->avatar($sha512);
my $users_dao = MyRedland::Lusers->new( app => $self->app );
$user = $users_dao->update( user => $user, fields => [qw/avatar/] );
system 'cp', $converted_file, $UPLOADS->child($sha512);
$self->render(
status => 200,
json => {
status => 200,
code => 'SUCESS',
description => 'Your avatar was correctly setup.',
}
);
$user = $users_dao->update( user => $user, fields => [qw/avatar/] );
system 'cp', $converted_file, $UPLOADS->child($sha512);
$self->render(
status => 200,
json => {
status => 200,
code => 'SUCESS',
description => 'Your avatar was correctly setup.',
}
);
}
sub get_avatar {
my $self = shift;
my $user = $self->current_user;
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.');
return;
}
$self->render(
format => 'png',
data => $UPLOADS->child($user->avatar)->slurp
);
my $self = shift;
my $user = $self->current_user;
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.' );
return;
}
$self->render(
format => 'png',
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;

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

View File

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

View File

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

View File

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

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;
$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 {

View File

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

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>