diff --git a/lib/MyRedland.pm b/lib/MyRedland.pm index ca0177b..3b74cf4 100644 --- a/lib/MyRedland.pm +++ b/lib/MyRedland.pm @@ -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'); diff --git a/lib/MyRedland/Controller/SubscriptionOrder.pm b/lib/MyRedland/Controller/SubscriptionOrder.pm new file mode 100644 index 0000000..f1e4844 --- /dev/null +++ b/lib/MyRedland/Controller/SubscriptionOrder.pm @@ -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; diff --git a/lib/MyRedland/Controller/User.pm b/lib/MyRedland/Controller/User.pm index 41205d0..3125ce4 100644 --- a/lib/MyRedland/Controller/User.pm +++ b/lib/MyRedland/Controller/User.pm @@ -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; diff --git a/lib/MyRedland/DB/Migrations.pm b/lib/MyRedland/DB/Migrations.pm index eeb38a1..ed74631 100644 --- a/lib/MyRedland/DB/Migrations.pm +++ b/lib/MyRedland/DB/Migrations.pm @@ -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; diff --git a/lib/MyRedland/Luser.pm b/lib/MyRedland/Luser.pm index 4af5219..a430b8d 100644 --- a/lib/MyRedland/Luser.pm +++ b/lib/MyRedland/Luser.pm @@ -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; diff --git a/lib/MyRedland/Lusers.pm b/lib/MyRedland/Lusers.pm index f0c8edb..0f67722 100644 --- a/lib/MyRedland/Lusers.pm +++ b/lib/MyRedland/Lusers.pm @@ -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; } diff --git a/lib/MyRedland/Posts.pm b/lib/MyRedland/Posts.pm index 408b003..12ea096 100644 --- a/lib/MyRedland/Posts.pm +++ b/lib/MyRedland/Posts.pm @@ -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; diff --git a/lib/MyRedland/Product.pm b/lib/MyRedland/Product.pm new file mode 100644 index 0000000..ffb70ad --- /dev/null +++ b/lib/MyRedland/Product.pm @@ -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; diff --git a/lib/MyRedland/Products.pm b/lib/MyRedland/Products.pm new file mode 100644 index 0000000..4ee6dca --- /dev/null +++ b/lib/MyRedland/Products.pm @@ -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; diff --git a/lib/MyRedland/Stripe.pm b/lib/MyRedland/Stripe.pm new file mode 100644 index 0000000..bcd82d3 --- /dev/null +++ b/lib/MyRedland/Stripe.pm @@ -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; diff --git a/lib/MyRedland/SubscriptionOrder.pm b/lib/MyRedland/SubscriptionOrder.pm new file mode 100644 index 0000000..480a657 --- /dev/null +++ b/lib/MyRedland/SubscriptionOrder.pm @@ -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; diff --git a/lib/MyRedland/SubscriptionOrders.pm b/lib/MyRedland/SubscriptionOrders.pm new file mode 100644 index 0000000..2551993 --- /dev/null +++ b/lib/MyRedland/SubscriptionOrders.pm @@ -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; diff --git a/public/css/styles.scss b/public/css/styles.scss index 917c710..9d4fc10 100644 --- a/public/css/styles.scss +++ b/public/css/styles.scss @@ -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 { diff --git a/templates/layouts/side_menu.html.ep b/templates/layouts/side_menu.html.ep index c0246b1..480a16a 100644 --- a/templates/layouts/side_menu.html.ep +++ b/templates/layouts/side_menu.html.ep @@ -3,5 +3,6 @@ % content_for 'side_menu' => begin Mi perfil Avatar + Opciones de subscripción % end <%= content %> diff --git a/templates/user/payment_success_get.html.ep b/templates/user/payment_success_get.html.ep new file mode 100644 index 0000000..f9e633c --- /dev/null +++ b/templates/user/payment_success_get.html.ep @@ -0,0 +1,9 @@ +% title 'Pago exitoso - Redland Official'; +% layout 'side_menu'; +% my $so = stash 'so'; +% my $pi = stash 'pi'; +
+

Pago exitoso.

+ +

Te has subscrito con exito en la modalidad "<%=$so->product->name%>" por la cantidad de <%=$so->to_pay/100%>€ al <%=$so->product->period%>.

+
diff --git a/templates/user/subscribe_get.html.ep b/templates/user/subscribe_get.html.ep new file mode 100644 index 0000000..28d6c56 --- /dev/null +++ b/templates/user/subscribe_get.html.ep @@ -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; + +
+

Ultimando tu subscripción.

+ + + + +

Estás suscribiendote en la modalidad "<%=$product->name%>"

+ +

Vas a pagar <%=$so->to_pay/100%>€ al <%=$product->period%>.

+ +
+ save_card ? 'checked' : ''%> <%=$so->renew_auto ? 'disabled' : ''%>/> + + renew_auto ? 'checked': ''%>/> + + +
+
+
+ +
+
+
+
+ +
+ + diff --git a/templates/user/subscription_options.html.ep b/templates/user/subscription_options.html.ep new file mode 100644 index 0000000..e180676 --- /dev/null +++ b/templates/user/subscription_options.html.ep @@ -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; + +
+

Opciones de subscripción.

+
+% for my $product (@$products) { +
+

<%=$product->price/100%>€

+

<%=$product->name%>

+

<%=$product->description%>

+ +
+% } +
+