From 908ccf5babfbfb60f182b8c5592274611e37dd02 Mon Sep 17 00:00:00 2001 From: Sylvain Date: Mon, 14 Dec 2020 17:42:23 +0100 Subject: [PATCH 1/9] handle reservations+subscription w/ schedule integration testing for payment schedule debug reservation process --- app/controllers/api/payments_controller.rb | 5 +- .../api/reservations_controller.rb | 4 +- app/models/reservation.rb | 4 +- app/services/reservations/reserve.rb | 3 +- app/workers/stripe_worker.rb | 1 - db/structure.sql | 57 +++++++------------ test/fixtures/history_values.yml | 10 +++- test/fixtures/machines.yml | 6 ++ test/fixtures/plans.yml | 26 +++++++++ test/fixtures/spaces.yml | 1 + test/fixtures/trainings.yml | 5 ++ .../subscriptions/create_as_user_test.rb | 46 +++++++++++++++ 12 files changed, 122 insertions(+), 46 deletions(-) diff --git a/app/controllers/api/payments_controller.rb b/app/controllers/api/payments_controller.rb index 6732ebddb..2a65817f4 100644 --- a/app/controllers/api/payments_controller.rb +++ b/app/controllers/api/payments_controller.rb @@ -103,7 +103,10 @@ class API::PaymentsController < API::ApiController current_user.id end is_reserve = Reservations::Reserve.new(user_id, current_user.invoicing_profile.id) - .pay_and_save(@reservation, payment_details: details, payment_intent_id: intent.id) + .pay_and_save(@reservation, + payment_details: details, + payment_intent_id: intent.id, + schedule: params[:cart_items][:reservation][:payment_schedule]) if intent.class == Stripe::PaymentIntent Stripe::PaymentIntent.update( intent.id, diff --git a/app/controllers/api/reservations_controller.rb b/app/controllers/api/reservations_controller.rb index 549ee2a07..50f0ee6f4 100644 --- a/app/controllers/api/reservations_controller.rb +++ b/app/controllers/api/reservations_controller.rb @@ -35,7 +35,9 @@ class API::ReservationsController < API::ApiController @reservation = Reservation.new(reservation_params) is_reserve = Reservations::Reserve.new(user_id, current_user.invoicing_profile.id) - .pay_and_save(@reservation, payment_details: price[:price_details]) + .pay_and_save(@reservation, + payment_details: price[:price_details], + schedule: params[:reservation][:payment_schedule]) if is_reserve SubscriptionExtensionAfterReservation.new(@reservation).extend_subscription_if_eligible diff --git a/app/models/reservation.rb b/app/models/reservation.rb index 9690ca756..9726a3766 100644 --- a/app/models/reservation.rb +++ b/app/models/reservation.rb @@ -120,7 +120,7 @@ class Reservation < ApplicationRecord pending_invoice_items.each(&:delete) end - def save_with_payment(operator_profile_id, payment_details, payment_intent_id = nil) + def save_with_payment(operator_profile_id, payment_details, payment_intent_id = nil, schedule: false) operator = InvoicingProfile.find(operator_profile_id)&.user method = operator&.admin? || (operator&.manager? && operator != user) ? nil : 'stripe' @@ -138,7 +138,7 @@ class Reservation < ApplicationRecord if plan_id self.subscription = Subscription.find_or_initialize_by(statistic_profile_id: statistic_profile_id) subscription.attributes = { plan_id: plan_id, statistic_profile_id: statistic_profile_id, expiration_date: nil } - if subscription.save_with_payment(operator_profile_id, invoice: false) + if subscription.save_with_payment(operator_profile_id, invoice: false, schedule: schedule) invoice.invoice_items.push InvoiceItem.new( amount: payment_details[:elements][:plan], description: subscription.plan.name, diff --git a/app/services/reservations/reserve.rb b/app/services/reservations/reserve.rb index d3d57587d..44f3a9fe3 100644 --- a/app/services/reservations/reserve.rb +++ b/app/services/reservations/reserve.rb @@ -10,8 +10,7 @@ class Reservations::Reserve end def pay_and_save(reservation, payment_details: nil, payment_intent_id: nil, schedule: false) - # TODO, pass the schedule payment up to subscription.save_with_payment(... schedule: schedule) reservation.statistic_profile_id = StatisticProfile.find_by(user_id: user_id).id - reservation.save_with_payment(operator_profile_id, payment_details, payment_intent_id) + reservation.save_with_payment(operator_profile_id, payment_details, payment_intent_id, schedule: schedule) end end diff --git a/app/workers/stripe_worker.rb b/app/workers/stripe_worker.rb index 14028554d..a44b63f78 100644 --- a/app/workers/stripe_worker.rb +++ b/app/workers/stripe_worker.rb @@ -53,7 +53,6 @@ class StripeWorker { name: object.name }, { api_key: Setting.get('stripe_secret_key') } ) - p.product else product = Stripe::Product.create( { diff --git a/db/structure.sql b/db/structure.sql index 3436e6be6..e5ce3175b 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -108,8 +108,8 @@ SET default_tablespace = ''; CREATE TABLE public.abuses ( id integer NOT NULL, - signaled_id integer, signaled_type character varying, + signaled_id integer, first_name character varying, last_name character varying, email character varying, @@ -187,8 +187,8 @@ CREATE TABLE public.addresses ( locality character varying, country character varying, postal_code character varying, - placeable_id integer, placeable_type character varying, + placeable_id integer, created_at timestamp without time zone, updated_at timestamp without time zone ); @@ -263,8 +263,8 @@ CREATE TABLE public.ar_internal_metadata ( CREATE TABLE public.assets ( id integer NOT NULL, - viewable_id integer, viewable_type character varying, + viewable_id integer, attachment character varying, type character varying, created_at timestamp without time zone, @@ -504,8 +504,8 @@ ALTER SEQUENCE public.coupons_id_seq OWNED BY public.coupons.id; CREATE TABLE public.credits ( id integer NOT NULL, - creditable_id integer, creditable_type character varying, + creditable_id integer, plan_id integer, hours integer, created_at timestamp without time zone, @@ -1046,8 +1046,8 @@ ALTER SEQUENCE public.invoice_items_id_seq OWNED BY public.invoice_items.id; CREATE TABLE public.invoices ( id integer NOT NULL, - invoiced_id integer, invoiced_type character varying, + invoiced_id integer, stp_invoice_id character varying, total integer, created_at timestamp without time zone, @@ -1227,15 +1227,15 @@ ALTER SEQUENCE public.machines_id_seq OWNED BY public.machines.id; CREATE TABLE public.notifications ( id integer NOT NULL, receiver_id integer, - attached_object_id integer, attached_object_type character varying, + attached_object_id integer, notification_type_id integer, is_read boolean DEFAULT false, created_at timestamp without time zone, updated_at timestamp without time zone, receiver_type character varying, is_send boolean DEFAULT false, - meta_data jsonb DEFAULT '{}'::jsonb + meta_data jsonb DEFAULT '"{}"'::jsonb ); @@ -1656,8 +1656,8 @@ CREATE TABLE public.prices ( id integer NOT NULL, group_id integer, plan_id integer, - priceable_id integer, priceable_type character varying, + priceable_id integer, amount integer, created_at timestamp without time zone NOT NULL, updated_at timestamp without time zone NOT NULL @@ -1972,8 +1972,8 @@ CREATE TABLE public.reservations ( message text, created_at timestamp without time zone, updated_at timestamp without time zone, - reservable_id integer, reservable_type character varying, + reservable_id integer, nb_reserve_places integer, statistic_profile_id integer ); @@ -2005,8 +2005,8 @@ ALTER SEQUENCE public.reservations_id_seq OWNED BY public.reservations.id; CREATE TABLE public.roles ( id integer NOT NULL, name character varying, - resource_id integer, resource_type character varying, + resource_id integer, created_at timestamp without time zone, updated_at timestamp without time zone ); @@ -2942,8 +2942,8 @@ CREATE TABLE public.users_roles ( CREATE TABLE public.wallet_transactions ( id integer NOT NULL, wallet_id integer, - transactable_id integer, transactable_type character varying, + transactable_id integer, transaction_type character varying, amount integer, created_at timestamp without time zone NOT NULL, @@ -4032,6 +4032,14 @@ ALTER TABLE ONLY public.roles ADD CONSTRAINT roles_pkey PRIMARY KEY (id); +-- +-- Name: schema_migrations schema_migrations_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.schema_migrations + ADD CONSTRAINT schema_migrations_pkey PRIMARY KEY (version); + + -- -- Name: settings settings_pkey; Type: CONSTRAINT; Schema: public; Owner: - -- @@ -5096,29 +5104,6 @@ CREATE INDEX profiles_lower_unaccent_last_name_trgm_idx ON public.profiles USING CREATE INDEX projects_search_vector_idx ON public.projects USING gin (search_vector); --- --- Name: unique_schema_migrations; Type: INDEX; Schema: public; Owner: - --- - -CREATE UNIQUE INDEX unique_schema_migrations ON public.schema_migrations USING btree (version); - - --- --- Name: accounting_periods accounting_periods_del_protect; Type: RULE; Schema: public; Owner: - --- - -CREATE RULE accounting_periods_del_protect AS - ON DELETE TO public.accounting_periods DO INSTEAD NOTHING; - - --- --- Name: accounting_periods accounting_periods_upd_protect; Type: RULE; Schema: public; Owner: - --- - -CREATE RULE accounting_periods_upd_protect AS - ON UPDATE TO public.accounting_periods DO INSTEAD NOTHING; - - -- -- Name: projects projects_search_content_trigger; Type: TRIGGER; Schema: public; Owner: - -- @@ -5653,7 +5638,6 @@ INSERT INTO "schema_migrations" (version) VALUES ('20140605125131'), ('20140605142133'), ('20140605151442'), -('20140606133116'), ('20140609092700'), ('20140609092827'), ('20140610153123'), @@ -5722,14 +5706,12 @@ INSERT INTO "schema_migrations" (version) VALUES ('20150507075620'), ('20150512123546'), ('20150520132030'), -('20150520133409'), ('20150526130729'), ('20150527153312'), ('20150529113555'), ('20150601125944'), ('20150603104502'), ('20150603104658'), -('20150603133050'), ('20150604081757'), ('20150604131525'), ('20150608142234'), @@ -5811,7 +5793,6 @@ INSERT INTO "schema_migrations" (version) VALUES ('20160905142700'), ('20160906094739'), ('20160906094847'), -('20160906145713'), ('20160915105234'), ('20161123104604'), ('20170109085345'), diff --git a/test/fixtures/history_values.yml b/test/fixtures/history_values.yml index 25add588e..be904051e 100644 --- a/test/fixtures/history_values.yml +++ b/test/fixtures/history_values.yml @@ -100,7 +100,7 @@ value_history_10: id: 10 setting_id: 10 invoicing_profile_id: 1 - value: YYMMmmmX[/VL]R[/A]S[/E] + value: YYMMmmmX[/VL]R[/A] created_at: 2018-12-17 11:23:01.603733000 Z updated_at: 2018-12-17 11:23:01.603733000 Z footprint: ed23a2eb1903befc977621bc3c3b19aad831fe550ebaa99e9299238b3d93c275 @@ -693,3 +693,11 @@ history_value_73: value: true created_at: 2020-06-17 10:48:19.002417000 Z updated_at: 2020-06-17 10:48:19.002417000 Z + +value_history_74: + id: 74 + setting_id: 10 + invoicing_profile_id: 1 + value: YYMMmmmX[/VL]R[/A]S[/E] + created_at: 2020-12-14 14:37:35.615124000 Z + updated_at: 2020-12-14 14:37:35.615124000 Z diff --git a/test/fixtures/machines.yml b/test/fixtures/machines.yml index 527ea6ca2..38a026d4e 100644 --- a/test/fixtures/machines.yml +++ b/test/fixtures/machines.yml @@ -22,6 +22,7 @@ machine_1: created_at: 2016-04-04 14:11:34.210242000 Z updated_at: 2016-04-04 14:11:34.210242000 Z slug: decoupeuse-laser + stp_product_id: prod_IZPyHpMCl38iQl machine_2: id: 2 @@ -38,6 +39,7 @@ machine_2: created_at: 2016-04-04 14:11:34.274025000 Z updated_at: 2016-04-04 14:11:34.274025000 Z slug: decoupeuse-vinyle + stp_product_id: prod_IZPyPShaaRgSML machine_3: id: 3 @@ -54,6 +56,7 @@ machine_3: created_at: 2016-04-04 14:11:34.304247000 Z updated_at: 2016-04-04 14:11:34.304247000 Z slug: shopbot-grande-fraiseuse + stp_product_id: prod_IZPyEjmdfMowhY machine_4: id: 4 @@ -67,6 +70,7 @@ machine_4: created_at: 2001-01-01 14:11:34.341810000 Z updated_at: 2001-01-01 14:11:34.341810000 Z slug: imprimante-3d + stp_product_id: prod_IZPy85vZOQpAo5 machine_5: id: 5 @@ -89,6 +93,7 @@ machine_5: created_at: 2016-04-04 14:11:34.379481000 Z updated_at: 2016-04-04 14:11:34.379481000 Z slug: petite-fraiseuse + stp_product_id: prod_IZPyBJEgbcpWMC machine_6: id: 6 @@ -123,3 +128,4 @@ machine_6: created_at: 2016-04-04 14:11:34.424740000 Z updated_at: 2016-04-04 14:11:34.424740000 Z slug: form1-imprimante-3d + stp_product_id: prod_IZPyjCzvLmLWAz diff --git a/test/fixtures/plans.yml b/test/fixtures/plans.yml index af996e226..834e07b46 100644 --- a/test/fixtures/plans.yml +++ b/test/fixtures/plans.yml @@ -15,6 +15,8 @@ plan_1: base_name: Mensuel ui_weight: 1 interval_count: 1 + slug: mensuel + stp_product_id: prod_IZPyXhfyNiGkWR plan_2: id: 2 @@ -32,6 +34,8 @@ plan_2: base_name: Sleede ui_weight: 5 interval_count: 2 + slug: sleede + stp_product_id: prod_IZPykam7a4satn plan_3: id: 3 @@ -49,4 +53,26 @@ plan_3: type: Plan base_name: Mensuel tarif réduit ui_weight: 0 + interval_count: 1* + slug: mensuel-tarif-reduit + stp_product_id: prod_IZPyM4N36h86G0 + +plan_schedulable: + id: 4 + name: Abonnement mensualisable - standard, association, year + amount: 113600 + interval: year + group_id: 1 + stp_plan_id: + created_at: 2020-12-14 14:10:11.056241000 Z + updated_at: 2020-12-14 14:10:11.137421000 Z + training_credit_nb: 1 + is_rolling: true + description: + type: Plan + base_name: Abonnement mensualisable + ui_weight: 10 interval_count: 1 + monthly_payment: true + slug: abonnement-mensualisable + stp_product_id: prod_IZQAhb9nLu4jfN diff --git a/test/fixtures/spaces.yml b/test/fixtures/spaces.yml index 242041286..8a50e82cc 100644 --- a/test/fixtures/spaces.yml +++ b/test/fixtures/spaces.yml @@ -7,3 +7,4 @@ space_1: created_at: 2017-02-15 15:55:04.123928000 Z updated_at: 2017-02-15 15:55:04.123928000 Z characteristics: Scie à chantourner, rabot, dégauchisseuse, chanfreineuse et pyrograveur + stp_product_id: prod_IZPyHjIb2owoB8 diff --git a/test/fixtures/trainings.yml b/test/fixtures/trainings.yml index a25c18e82..24d9521bd 100644 --- a/test/fixtures/trainings.yml +++ b/test/fixtures/trainings.yml @@ -7,6 +7,7 @@ training_1: nb_total_places: slug: formation-imprimante-3d description: + stp_product_id: prod_IZPyXw6BDBBFOg training_2: id: 2 @@ -16,6 +17,7 @@ training_2: nb_total_places: slug: formation-laser-vinyle description: + stp_product_id: prod_IZPytTl1wSB5jH training_3: id: 3 @@ -25,6 +27,7 @@ training_3: nb_total_places: slug: formation-petite-fraiseuse-numerique description: + stp_product_id: prod_IZPyAA1A4QfEyL training_4: id: 4 @@ -34,6 +37,7 @@ training_4: nb_total_places: slug: formation-shopbot-grande-fraiseuse description: + stp_product_id: prod_IZPyU27NjDSmqB training_5: id: 5 @@ -43,3 +47,4 @@ training_5: nb_total_places: slug: formation-logiciel-2d description: + stp_product_id: prod_IZPyvdgQHMByB3 diff --git a/test/integration/subscriptions/create_as_user_test.rb b/test/integration/subscriptions/create_as_user_test.rb index 74e83e6d6..8adcb7234 100644 --- a/test/integration/subscriptions/create_as_user_test.rb +++ b/test/integration/subscriptions/create_as_user_test.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require 'test_helper' + class Subscriptions::CreateAsUserTest < ActionDispatch::IntegrationTest setup do @user = User.find_by(username: 'jdupond') @@ -166,4 +168,48 @@ class Subscriptions::CreateAsUserTest < ActionDispatch::IntegrationTest assert_equal invoice.wallet_amount / 100.0, transaction.amount assert_equal invoice.wallet_transaction_id, transaction.id end + + test 'user takes a subscription with payment schedule' do + plan = Plan.find_by(group_id: @user.group.id, type: 'Plan', base_name: 'Abonnement mensualisable') + + VCR.use_cassette('subscriptions_user_setup_intent') do + get "/api/payments/setup_intent/#{@user.id}" + end + + # Check response format & status + assert_equal 200, response.status, response.body + assert_equal Mime[:json], response.content_type + + # Check the correct object was signaled + setup_intent = json_response(response.body) + + VCR.use_cassette('subscriptions_user_create_with_payment_schedule') do + post '/api/payments/confirm_payment', + params: { + payment_method_id: stripe_payment_method, + cart_items: { + subscription: { + plan_id: plan.id, + payment_schedule: true, + payment_method: 'stripe' + } + }, + setup_intent_id: setup_intent[:client_secret] + }.to_json, headers: default_headers + end + + # Check response format & status + assert_equal 201, response.status, response.body + assert_equal Mime[:json], response.content_type + + # Check the correct plan was subscribed + subscription = json_response(response.body) + assert_equal plan.id, subscription[:plan_id], 'subscribed plan does not match' + + # Check that the user has the correct subscription + assert_not_nil @user.subscription, "user's subscription was not found" + assert_not_nil @user.subscription.plan, "user's subscribed plan was not found" + assert_equal plan.id, @user.subscription.plan_id, "user's plan does not match" + + end end From ccff4631653e1ef4cf8a4bf50a53c1f8f296ab5a Mon Sep 17 00:00:00 2001 From: Sylvain Date: Tue, 15 Dec 2020 15:48:13 +0100 Subject: [PATCH 2/9] test subscription with schedule by user --- Rakefile | 2 +- app/controllers/api/payments_controller.rb | 2 +- config/application.rb | 3 +- .../subscriptions/create_as_user_test.rb | 43 ++++++++++++------- test/test_helper.rb | 4 +- 5 files changed, 34 insertions(+), 20 deletions(-) diff --git a/Rakefile b/Rakefile index ba6b733dd..e85f91391 100644 --- a/Rakefile +++ b/Rakefile @@ -1,6 +1,6 @@ # Add your own tasks in files placed in lib/tasks ending in .rake, # for example lib/tasks/capistrano.rake, and they will automatically be available to Rake. -require File.expand_path('../config/application', __FILE__) +require_relative 'config/application' Rails.application.load_tasks diff --git a/app/controllers/api/payments_controller.rb b/app/controllers/api/payments_controller.rb index 2a65817f4..ba5b9b3e5 100644 --- a/app/controllers/api/payments_controller.rb +++ b/app/controllers/api/payments_controller.rb @@ -72,7 +72,7 @@ class API::PaymentsController < API::ApiController user = User.find(params[:user_id]) key = Setting.get('stripe_secret_key') @intent = Stripe::SetupIntent.create({ customer: user.stp_customer_id }, { api_key: key }) - render json: { client_secret: @intent.client_secret } + render json: { id: @intent.id, client_secret: @intent.client_secret } end def confirm_payment_schedule diff --git a/config/application.rb b/config/application.rb index ec523ff31..ef31793ab 100644 --- a/config/application.rb +++ b/config/application.rb @@ -10,7 +10,7 @@ require 'action_view/railtie' require 'action_mailer/railtie' require 'active_job/railtie' # require 'action_cable/engine' -require 'rails/test_unit/railtie' if Rails.env.test? +require 'rails/test_unit/railtie' # require 'sprockets/railtie' require 'elasticsearch/rails/instrumentation' require 'elasticsearch/persistence/model' @@ -56,6 +56,7 @@ module Fablab config.generators do |g| g.orm :active_record + g.test_framework :mini_test end if Rails.env.development? diff --git a/test/integration/subscriptions/create_as_user_test.rb b/test/integration/subscriptions/create_as_user_test.rb index 8adcb7234..221e47d61 100644 --- a/test/integration/subscriptions/create_as_user_test.rb +++ b/test/integration/subscriptions/create_as_user_test.rb @@ -172,29 +172,42 @@ class Subscriptions::CreateAsUserTest < ActionDispatch::IntegrationTest test 'user takes a subscription with payment schedule' do plan = Plan.find_by(group_id: @user.group.id, type: 'Plan', base_name: 'Abonnement mensualisable') - VCR.use_cassette('subscriptions_user_setup_intent') do - get "/api/payments/setup_intent/#{@user.id}" - end - - # Check response format & status - assert_equal 200, response.status, response.body - assert_equal Mime[:json], response.content_type - - # Check the correct object was signaled - setup_intent = json_response(response.body) - VCR.use_cassette('subscriptions_user_create_with_payment_schedule') do - post '/api/payments/confirm_payment', + get "/api/payments/setup_intent/#{@user.id}" + + # Check response format & status + assert_equal 200, response.status, response.body + assert_equal Mime[:json], response.content_type + + # Check the response + setup_intent = json_response(response.body) + assert_not_nil setup_intent[:client_secret] + assert_not_nil setup_intent[:id] + assert_match /^#{setup_intent[:id]}_secret_/, setup_intent[:client_secret] + + # Confirm the intent + stripe_res = Stripe::SetupIntent.confirm( + setup_intent[:id], + { payment_method: stripe_payment_method }, + { api_key: Setting.get('stripe_secret_key') } + ) + + # check the confirmation + assert_equal setup_intent[:id], stripe_res.id + assert_equal 'succeeded', stripe_res.status + assert_equal 'off_session', stripe_res.usage + + + post '/api/payments/confirm_payment_schedule', params: { - payment_method_id: stripe_payment_method, + setup_intent_id: setup_intent[:id], cart_items: { subscription: { plan_id: plan.id, payment_schedule: true, payment_method: 'stripe' } - }, - setup_intent_id: setup_intent[:client_secret] + } }.to_json, headers: default_headers end diff --git a/test/test_helper.rb b/test/test_helper.rb index 019001e6b..a15ca6611 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -4,7 +4,7 @@ require 'coveralls' Coveralls.wear!('rails') ENV['RAILS_ENV'] ||= 'test' -require File.expand_path('../config/environment', __dir__) +require_relative '../config/environment' require 'action_dispatch' require 'rails/test_help' require 'vcr' @@ -23,7 +23,7 @@ Minitest::Reporters.use! [Minitest::Reporters::DefaultReporter.new(color: true)] class ActiveSupport::TestCase # Add more helper methods to be used by all tests here... - + ActiveRecord::Migration.check_pending! fixtures :all def json_response(body) From e3a81f6dbee9f03593a317be1a06c363e29c3275 Mon Sep 17 00:00:00 2001 From: Sylvain Date: Tue, 15 Dec 2020 16:53:11 +0100 Subject: [PATCH 3/9] test reservation with schedule by user --- test/integration/reservations/create_test.rb | 85 +++++++++++++++++++ .../subscriptions/create_as_user_test.rb | 10 ++- 2 files changed, 91 insertions(+), 4 deletions(-) diff --git a/test/integration/reservations/create_test.rb b/test/integration/reservations/create_test.rb index 88898b01a..e0ff756e4 100644 --- a/test/integration/reservations/create_test.rb +++ b/test/integration/reservations/create_test.rb @@ -650,5 +650,90 @@ module Reservations assert_equal 0, @user_without_subscription.subscriptions.count assert_nil @user_without_subscription.subscribed_plan end + + + test 'user reserves a training and a subscription with payment schedule' do + login_as(@user_without_subscription, scope: :user) + + reservations_count = Reservation.count + invoice_count = Invoice.count + invoice_items_count = InvoiceItem.count + subscriptions_count = Subscription.count + users_credit_count = UsersCredit.count + payment_schedule_count = PaymentSchedule.count + payment_schedule_items_count = PaymentScheduleItem.count + + training = Training.find(1) + availability = training.availabilities.first + plan = Plan.find_by(group_id: @user_without_subscription.group.id, type: 'Plan', base_name: 'Abonnement mensualisable') + + VCR.use_cassette('reservations_training_subscription_with_payment_schedule') do + get "/api/payments/setup_intent/#{@user_without_subscription.id}" + + # Check response format & status + assert_equal 200, response.status, response.body + assert_equal Mime[:json], response.content_type + + # Check the response + setup_intent = json_response(response.body) + assert_not_nil setup_intent[:client_secret] + assert_not_nil setup_intent[:id] + assert_match /^#{setup_intent[:id]}_secret_/, setup_intent[:client_secret] + + # Confirm the intent + stripe_res = Stripe::SetupIntent.confirm( + setup_intent[:id], + { payment_method: stripe_payment_method }, + { api_key: Setting.get('stripe_secret_key') } + ) + + # check the confirmation + assert_equal setup_intent[:id], stripe_res.id + assert_equal 'succeeded', stripe_res.status + assert_equal 'off_session', stripe_res.usage + + + post '/api/payments/confirm_payment_schedule', + params: { + setup_intent_id: setup_intent[:id], + cart_items: { + reservation: { + reservable_id: training.id, + reservable_type: training.class.name, + slots_attributes: [ + { + start_at: availability.start_at.to_s(:iso8601), + end_at: (availability.start_at + 1.hour).to_s(:iso8601), + availability_id: availability.id + } + ], + plan_id: plan.id, + payment_schedule: true + } + } + }.to_json, headers: default_headers + end + + # Check response format & status + assert_equal 201, response.status, response.body + assert_equal Mime[:json], response.content_type + assert_equal reservations_count + 1, Reservation.count, 'missing the reservation' + assert_equal invoice_count, Invoice.count, "an invoice was generated but it shouldn't" + assert_equal invoice_items_count, InvoiceItem.count, "some invoice items were generated but they shouldn't" + assert_equal users_credit_count, UsersCredit.count, "user's credits count has changed but it shouldn't" + assert_equal subscriptions_count + 1, Subscription.count, 'missing the subscription' + assert_equal payment_schedule_count + 1, PaymentSchedule.count, 'missing the payment schedule' + assert_equal payment_schedule_items_count + 12, PaymentScheduleItem.count, 'missing some payment schedule items' + + # subscription assertions + assert_equal 1, @user_without_subscription.subscriptions.count + assert_not_nil @user_without_subscription.subscribed_plan, "user's subscribed plan was not found" + assert_not_nil @user_without_subscription.subscription, "user's subscription was not found" + assert_equal plan.id, @user_without_subscription.subscribed_plan.id, "user's plan does not match" + + # Check the answer + reservation = json_response(response.body) + assert_equal plan.id, reservation[:user][:subscribed_plan][:id], 'subscribed plan does not match' + end end end diff --git a/test/integration/subscriptions/create_as_user_test.rb b/test/integration/subscriptions/create_as_user_test.rb index 221e47d61..155aa8a59 100644 --- a/test/integration/subscriptions/create_as_user_test.rb +++ b/test/integration/subscriptions/create_as_user_test.rb @@ -171,6 +171,8 @@ class Subscriptions::CreateAsUserTest < ActionDispatch::IntegrationTest test 'user takes a subscription with payment schedule' do plan = Plan.find_by(group_id: @user.group.id, type: 'Plan', base_name: 'Abonnement mensualisable') + payment_schedule_count = PaymentSchedule.count + payment_schedule_items_count = PaymentScheduleItem.count VCR.use_cassette('subscriptions_user_create_with_payment_schedule') do get "/api/payments/setup_intent/#{@user.id}" @@ -204,16 +206,17 @@ class Subscriptions::CreateAsUserTest < ActionDispatch::IntegrationTest cart_items: { subscription: { plan_id: plan.id, - payment_schedule: true, - payment_method: 'stripe' + payment_schedule: true } } }.to_json, headers: default_headers end - # Check response format & status + # Check generalities assert_equal 201, response.status, response.body assert_equal Mime[:json], response.content_type + assert_equal payment_schedule_count + 1, PaymentSchedule.count, 'missing the payment schedule' + assert_equal payment_schedule_items_count + 12, PaymentScheduleItem.count, 'missing some payment schedule items' # Check the correct plan was subscribed subscription = json_response(response.body) @@ -223,6 +226,5 @@ class Subscriptions::CreateAsUserTest < ActionDispatch::IntegrationTest assert_not_nil @user.subscription, "user's subscription was not found" assert_not_nil @user.subscription.plan, "user's subscribed plan was not found" assert_equal plan.id, @user.subscription.plan_id, "user's plan does not match" - end end From a5e48fa379a524d04af9f6c09b5208e9b2e18cc2 Mon Sep 17 00:00:00 2001 From: Sylvain Date: Tue, 15 Dec 2020 17:01:54 +0100 Subject: [PATCH 4/9] test subscription with schedule by admin --- .rubocop.yml | 1 + .../subscriptions/create_as_admin_test.rb | 148 +++++++++++++----- 2 files changed, 106 insertions(+), 43 deletions(-) diff --git a/.rubocop.yml b/.rubocop.yml index fa5d4d57e..2eca3bfc2 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -16,6 +16,7 @@ Metrics/BlockLength: - 'lib/tasks/**/*.rake' - 'config/routes.rb' - 'app/pdfs/pdf/*.rb' + - 'test/**/*.rb' Metrics/ParameterLists: CountKeywordArgs: false Style/BracesAroundHashParameters: diff --git a/test/integration/subscriptions/create_as_admin_test.rb b/test/integration/subscriptions/create_as_admin_test.rb index 534ece8bf..e959403fa 100644 --- a/test/integration/subscriptions/create_as_admin_test.rb +++ b/test/integration/subscriptions/create_as_admin_test.rb @@ -1,59 +1,121 @@ # frozen_string_literal: true -module Subscriptions - class CreateAsAdminTest < ActionDispatch::IntegrationTest - setup do - @admin = User.find_by(username: 'admin') - login_as(@admin, scope: :user) +require 'test_helper' + +class Subscriptions::CreateAsAdminTest < ActionDispatch::IntegrationTest + setup do + @admin = User.find_by(username: 'admin') + login_as(@admin, scope: :user) + end + + test 'admin successfully takes a subscription for a user' do + user = User.find_by(username: 'jdupond') + plan = Plan.find_by(group_id: user.group.id, type: 'Plan', base_name: 'Mensuel') + + VCR.use_cassette('subscriptions_admin_create_success') do + post '/api/subscriptions', + params: { + subscription: { + plan_id: plan.id, + user_id: user.id + } + }.to_json, headers: default_headers end - test 'admin successfully takes a subscription for a user' do - user = User.find_by(username: 'jdupond') - plan = Plan.find_by(group_id: user.group.id, type: 'Plan', base_name: 'Mensuel') + # Check response format & status + assert_equal 201, response.status, response.body + assert_equal Mime[:json], response.content_type - VCR.use_cassette('subscriptions_admin_create_success') do - post '/api/subscriptions', - params: { - subscription: { - plan_id: plan.id, - user_id: user.id - } - }.to_json, headers: default_headers - end + # Check the correct plan was subscribed + subscription = json_response(response.body) + assert_equal plan.id, subscription[:plan_id], 'subscribed plan does not match' + + # Check that the user has only one subscription + assert_equal 1, user.subscriptions.count + + # Check that the user has the correct subscription + assert_not_nil user.subscription, "user's subscription was not found" + assert_not_nil user.subscription.plan, "user's subscribed plan was not found" + assert_equal plan.id, user.subscription.plan_id, "user's plan does not match" + + # Check that the training credits were set correctly + assert_empty user.training_credits, 'training credits were not reset' + assert_equal user.subscription.plan.training_credit_nb, plan.training_credit_nb, 'trainings credits were not allocated' + + # Check that the user benefit from prices of his plan + printer = Machine.find_by(slug: 'imprimante-3d') + assert_equal 15, (printer.prices.find_by(group_id: user.group_id, plan_id: user.subscription.plan_id).amount / 100.00), 'machine hourly price does not match' + + # Check notification was sent to the user + notification = Notification.find_by(notification_type_id: NotificationType.find_by_name('notify_member_subscribed_plan'), attached_object_type: 'Subscription', attached_object_id: subscription[:id]) + assert_not_nil notification, 'user notification was not created' + assert_equal user.id, notification.receiver_id, 'wrong user notified' + + # Check generated invoice + invoice = Invoice.find_by(invoiced_type: 'Subscription', invoiced_id: subscription[:id]) + assert_invoice_pdf invoice + assert_equal plan.amount, invoice.total, 'Invoice total price does not match the bought subscription' + end + + test 'admin takes a subscription with a payment schedule' do + user = User.find_by(username: 'jdupond') + plan = Plan.find_by(group_id: user.group.id, type: 'Plan', base_name: 'Abonnement mensualisable') + payment_schedule_count = PaymentSchedule.count + payment_schedule_items_count = PaymentScheduleItem.count + + VCR.use_cassette('subscriptions_admin_create_with_payment_schedule') do + get "/api/payments/setup_intent/#{user.id}" # Check response format & status - assert_equal 201, response.status, response.body + assert_equal 200, response.status, response.body assert_equal Mime[:json], response.content_type - # Check the correct plan was subscribed - subscription = json_response(response.body) - assert_equal plan.id, subscription[:plan_id], 'subscribed plan does not match' + # Check the response + setup_intent = json_response(response.body) + assert_not_nil setup_intent[:client_secret] + assert_not_nil setup_intent[:id] + assert_match /^#{setup_intent[:id]}_secret_/, setup_intent[:client_secret] - # Check that the user has only one subscription - assert_equal 1, user.subscriptions.count + # Confirm the intent + stripe_res = Stripe::SetupIntent.confirm( + setup_intent[:id], + { payment_method: stripe_payment_method }, + { api_key: Setting.get('stripe_secret_key') } + ) - # Check that the user has the correct subscription - assert_not_nil user.subscription, "user's subscription was not found" - assert_not_nil user.subscription.plan, "user's subscribed plan was not found" - assert_equal plan.id, user.subscription.plan_id, "user's plan does not match" + # check the confirmation + assert_equal setup_intent[:id], stripe_res.id + assert_equal 'succeeded', stripe_res.status + assert_equal 'off_session', stripe_res.usage - # Check that the training credits were set correctly - assert_empty user.training_credits, 'training credits were not reset' - assert_equal user.subscription.plan.training_credit_nb, plan.training_credit_nb, 'trainings credits were not allocated' - # Check that the user benefit from prices of his plan - printer = Machine.find_by(slug: 'imprimante-3d') - assert_equal 15, (printer.prices.find_by(group_id: user.group_id, plan_id: user.subscription.plan_id).amount / 100.00), 'machine hourly price does not match' - - # Check notification was sent to the user - notification = Notification.find_by(notification_type_id: NotificationType.find_by_name('notify_member_subscribed_plan'), attached_object_type: 'Subscription', attached_object_id: subscription[:id]) - assert_not_nil notification, 'user notification was not created' - assert_equal user.id, notification.receiver_id, 'wrong user notified' - - # Check generated invoice - invoice = Invoice.find_by(invoiced_type: 'Subscription', invoiced_id: subscription[:id]) - assert_invoice_pdf invoice - assert_equal plan.amount, invoice.total, 'Invoice total price does not match the bought subscription' + post '/api/payments/confirm_payment_schedule', + params: { + setup_intent_id: setup_intent[:id], + cart_items: { + subscription: { + plan_id: plan.id, + payment_schedule: true, + user_id: user.id, + payment_method: 'stripe' + } + } + }.to_json, headers: default_headers end + + # Check generalities + assert_equal 201, response.status, response.body + assert_equal Mime[:json], response.content_type + assert_equal payment_schedule_count + 1, PaymentSchedule.count, 'missing the payment schedule' + assert_equal payment_schedule_items_count + 12, PaymentScheduleItem.count, 'missing some payment schedule items' + + # Check the correct plan was subscribed + subscription = json_response(response.body) + assert_equal plan.id, subscription[:plan_id], 'subscribed plan does not match' + + # Check that the user has the correct subscription + assert_not_nil user.subscription, "user's subscription was not found" + assert_not_nil user.subscription.plan, "user's subscribed plan was not found" + assert_equal plan.id, user.subscription.plan_id, "user's plan does not match" end end From 6c8d65fba12c77d5f45dee7249f0329e89de036e Mon Sep 17 00:00:00 2001 From: Sylvain Date: Tue, 15 Dec 2020 17:14:58 +0100 Subject: [PATCH 5/9] test reservation with schedule by admin --- .../reservations/create_as_admin_test.rb | 909 ++++++------ test/integration/reservations/create_test.rb | 1316 ++++++++--------- 2 files changed, 1138 insertions(+), 1087 deletions(-) diff --git a/test/integration/reservations/create_as_admin_test.rb b/test/integration/reservations/create_as_admin_test.rb index 92dd752fc..813e8c0b1 100644 --- a/test/integration/reservations/create_as_admin_test.rb +++ b/test/integration/reservations/create_as_admin_test.rb @@ -1,82 +1,471 @@ # frozen_string_literal: true -module Reservations - class CreateAsAdminTest < ActionDispatch::IntegrationTest - setup do - @user_without_subscription = User.members.without_subscription.first - @user_with_subscription = User.members.with_subscription.second - @admin = User.with_role(:admin).first - login_as(@admin, scope: :user) - end +require 'test_helper' - test 'user without subscription reserves a machine with success' do - machine = Machine.find(6) - availability = machine.availabilities.first +class Reservations::CreateAsAdminTest < ActionDispatch::IntegrationTest + setup do + @user_without_subscription = User.members.without_subscription.first + @user_with_subscription = User.members.with_subscription.second + @admin = User.with_role(:admin).first + login_as(@admin, scope: :user) + end - reservations_count = Reservation.count - invoice_count = Invoice.count - invoice_items_count = InvoiceItem.count - users_credit_count = UsersCredit.count - - post reservations_path, params: { reservation: { - user_id: @user_without_subscription.id, - reservable_id: machine.id, - reservable_type: machine.class.name, - slots_attributes: [ - { - start_at: availability.start_at.to_s(:iso8601), - end_at: (availability.start_at + 1.hour).to_s(:iso8601), - availability_id: availability.id - } - ] - } }.to_json, headers: default_headers - - # general assertions - assert_equal 201, response.status - assert_equal reservations_count + 1, Reservation.count - assert_equal invoice_count + 1, Invoice.count - assert_equal invoice_items_count + 1, InvoiceItem.count - assert_equal users_credit_count, UsersCredit.count - - # subscription assertions - assert_equal 0, @user_without_subscription.subscriptions.count - assert_nil @user_without_subscription.subscribed_plan - - # reservation assertions - reservation = Reservation.last - - assert reservation.invoice - assert_equal 1, reservation.invoice.invoice_items.count - - # invoice assertions - invoice = reservation.invoice - - assert invoice.stp_payment_intent_id.blank? - refute invoice.total.blank? - - # invoice_items assertions - invoice_item = InvoiceItem.last - - assert_equal machine.prices.find_by(group_id: @user_without_subscription.group_id, plan_id: nil).amount, invoice_item.amount - - # invoice assertions - invoice = Invoice.find_by(invoiced: reservation) - assert_invoice_pdf invoice - - # notification - assert_not_empty Notification.where(attached_object: reservation) - end - - test 'user without subscription reserves a training with success' do - training = Training.first - availability = training.availabilities.first - - reservations_count = Reservation.count - invoice_count = Invoice.count - invoice_items_count = InvoiceItem.count + test 'user without subscription reserves a machine with success' do + machine = Machine.find(6) + availability = machine.availabilities.first + reservations_count = Reservation.count + invoice_count = Invoice.count + invoice_items_count = InvoiceItem.count + users_credit_count = UsersCredit.count + + post reservations_path, params: { reservation: { + user_id: @user_without_subscription.id, + reservable_id: machine.id, + reservable_type: machine.class.name, + slots_attributes: [ + { + start_at: availability.start_at.to_s(:iso8601), + end_at: (availability.start_at + 1.hour).to_s(:iso8601), + availability_id: availability.id + } + ] + } }.to_json, headers: default_headers + + # general assertions + assert_equal 201, response.status + assert_equal reservations_count + 1, Reservation.count + assert_equal invoice_count + 1, Invoice.count + assert_equal invoice_items_count + 1, InvoiceItem.count + assert_equal users_credit_count, UsersCredit.count + + # subscription assertions + assert_equal 0, @user_without_subscription.subscriptions.count + assert_nil @user_without_subscription.subscribed_plan + + # reservation assertions + reservation = Reservation.last + + assert reservation.invoice + assert_equal 1, reservation.invoice.invoice_items.count + + # invoice assertions + invoice = reservation.invoice + + assert invoice.stp_payment_intent_id.blank? + refute invoice.total.blank? + + # invoice_items assertions + invoice_item = InvoiceItem.last + + assert_equal machine.prices.find_by(group_id: @user_without_subscription.group_id, plan_id: nil).amount, invoice_item.amount + + # invoice assertions + invoice = Invoice.find_by(invoiced: reservation) + assert_invoice_pdf invoice + + # notification + assert_not_empty Notification.where(attached_object: reservation) + end + + test 'user without subscription reserves a training with success' do + training = Training.first + availability = training.availabilities.first + + reservations_count = Reservation.count + invoice_count = Invoice.count + invoice_items_count = InvoiceItem.count + + post reservations_path, params: { reservation: { + user_id: @user_without_subscription.id, + reservable_id: training.id, + reservable_type: training.class.name, + slots_attributes: [ + { + start_at: availability.start_at.to_s(:iso8601), + end_at: (availability.start_at + 1.hour).to_s(:iso8601), + availability_id: availability.id + } + ] + } }.to_json, headers: default_headers + + # general assertions + assert_equal 201, response.status + assert_equal reservations_count + 1, Reservation.count + assert_equal invoice_count + 1, Invoice.count + assert_equal invoice_items_count + 1, InvoiceItem.count + + # subscription assertions + assert_equal 0, @user_without_subscription.subscriptions.count + assert_nil @user_without_subscription.subscribed_plan + + # reservation assertions + reservation = Reservation.last + + assert reservation.invoice + assert_equal 1, reservation.invoice.invoice_items.count + + # invoice assertions + invoice = reservation.invoice + + assert invoice.stp_payment_intent_id.blank? + refute invoice.total.blank? + # invoice_items + invoice_item = InvoiceItem.last + + assert_equal invoice_item.amount, training.amount_by_group(@user_without_subscription.group_id).amount + + # invoice assertions + invoice = Invoice.find_by(invoiced: reservation) + assert_invoice_pdf invoice + + # notification + assert_not_empty Notification.where(attached_object: reservation) + end + + test 'user with subscription reserves a machine with success' do + plan = @user_with_subscription.subscribed_plan + machine = Machine.find(6) + availability = machine.availabilities.first + + reservations_count = Reservation.count + invoice_count = Invoice.count + invoice_items_count = InvoiceItem.count + users_credit_count = UsersCredit.count + + post reservations_path, params: { reservation: { + user_id: @user_with_subscription.id, + reservable_id: machine.id, + reservable_type: machine.class.name, + slots_attributes: [ + { + start_at: availability.start_at.to_s(:iso8601), + end_at: (availability.start_at + 1.hour).to_s(:iso8601), + availability_id: availability.id + }, + { + start_at: (availability.start_at + 1.hour).to_s(:iso8601), + end_at: (availability.start_at + 2.hours).to_s(:iso8601), + availability_id: availability.id + } + ] + } }.to_json, headers: default_headers + + # general assertions + assert_equal 201, response.status + assert_equal reservations_count + 1, Reservation.count + assert_equal invoice_count + 1, Invoice.count + assert_equal invoice_items_count + 2, InvoiceItem.count + assert_equal users_credit_count + 1, UsersCredit.count + + # subscription assertions + assert_equal 1, @user_with_subscription.subscriptions.count + assert_not_nil @user_with_subscription.subscribed_plan + assert_equal plan.id, @user_with_subscription.subscribed_plan.id + + # reservation assertions + reservation = Reservation.last + + assert reservation.invoice + assert_equal 2, reservation.invoice.invoice_items.count + + # invoice assertions + invoice = reservation.invoice + + assert invoice.stp_payment_intent_id.blank? + refute invoice.total.blank? + + # invoice_items assertions + invoice_items = InvoiceItem.last(2) + machine_price = machine.prices.find_by(group_id: @user_with_subscription.group_id, plan_id: plan.id).amount + + assert(invoice_items.any? { |ii| ii.amount.zero? }) + assert(invoice_items.any? { |ii| ii.amount == machine_price }) + + # users_credits assertions + users_credit = UsersCredit.last + + assert_equal @user_with_subscription, users_credit.user + assert_equal [reservation.slots.count, plan.machine_credits.find_by(creditable_id: machine.id).hours].min, users_credit.hours_used + + # invoice assertions + invoice = Invoice.find_by(invoiced: reservation) + assert_invoice_pdf invoice + + # notification + assert_not_empty Notification.where(attached_object: reservation) + end + + test 'user without subscription reserves a machine and pay by wallet with success' do + @vlonchamp = User.find_by(username: 'vlonchamp') + machine = Machine.find(6) + availability = machine.availabilities.first + + reservations_count = Reservation.count + invoice_count = Invoice.count + invoice_items_count = InvoiceItem.count + users_credit_count = UsersCredit.count + + post reservations_path, params: { reservation: { + user_id: @vlonchamp.id, + reservable_id: machine.id, + reservable_type: machine.class.name, + slots_attributes: [ + { + start_at: availability.start_at.to_s(:iso8601), + end_at: (availability.start_at + 1.hour).to_s(:iso8601), + availability_id: availability.id + } + ] + } }.to_json, headers: default_headers + + # general assertions + assert_equal 201, response.status + assert_equal reservations_count + 1, Reservation.count + assert_equal invoice_count + 1, Invoice.count + assert_equal invoice_items_count + 1, InvoiceItem.count + assert_equal users_credit_count, UsersCredit.count + + # subscription assertions + assert_equal 0, @user_without_subscription.subscriptions.count + assert_nil @user_without_subscription.subscribed_plan + + # reservation assertions + reservation = Reservation.last + + assert reservation.invoice + assert_equal 1, reservation.invoice.invoice_items.count + + # invoice assertions + invoice = reservation.invoice + + assert invoice.stp_payment_intent_id.blank? + refute invoice.total.blank? + + # invoice_items assertions + invoice_item = InvoiceItem.last + + assert_equal machine.prices.find_by(group_id: @vlonchamp.group_id, plan_id: nil).amount, invoice_item.amount + + # invoice assertions + invoice = Invoice.find_by(invoiced: reservation) + assert_invoice_pdf invoice + + # notification + assert_not_empty Notification.where(attached_object: reservation) + + # wallet + assert_equal @vlonchamp.wallet.amount, 0 + assert_equal @vlonchamp.wallet.wallet_transactions.count, 2 + transaction = @vlonchamp.wallet.wallet_transactions.last + assert_equal transaction.transaction_type, 'debit' + assert_equal transaction.amount, 10 + assert_equal transaction.amount, invoice.wallet_amount / 100.0 + assert_equal transaction.id, invoice.wallet_transaction_id + end + + test 'user reserves a machine and a subscription pay by wallet with success' do + @vlonchamp = User.find_by(username: 'vlonchamp') + machine = Machine.find(6) + availability = machine.availabilities.first + plan = Plan.find_by(group_id: @vlonchamp.group.id, type: 'Plan', base_name: 'Mensuel tarif réduit') + + reservations_count = Reservation.count + invoice_count = Invoice.count + invoice_items_count = InvoiceItem.count + users_credit_count = UsersCredit.count + wallet_transactions_count = WalletTransaction.count + + post reservations_path, params: { reservation: { + user_id: @vlonchamp.id, + reservable_id: machine.id, + reservable_type: machine.class.name, + plan_id: plan.id, + slots_attributes: [ + { + start_at: availability.start_at.to_s(:iso8601), + end_at: (availability.start_at + 1.hour).to_s(:iso8601), + availability_id: availability.id + } + ] + } }.to_json, headers: default_headers + + # general assertions + assert_equal 201, response.status + assert_equal reservations_count + 1, Reservation.count + assert_equal invoice_count + 1, Invoice.count + assert_equal invoice_items_count + 2, InvoiceItem.count + assert_equal users_credit_count + 1, UsersCredit.count + assert_equal wallet_transactions_count + 1, WalletTransaction.count + + # subscription assertions + assert_equal 1, @vlonchamp.subscriptions.count + assert_not_nil @vlonchamp.subscribed_plan + assert_equal plan.id, @vlonchamp.subscribed_plan.id + + # reservation assertions + reservation = Reservation.last + + assert reservation.invoice + assert_equal 2, reservation.invoice.invoice_items.count + + # invoice assertions + invoice = reservation.invoice + + assert invoice.stp_payment_intent_id.blank? + refute invoice.total.blank? + assert_equal invoice.total, 2000 + + # invoice assertions + invoice = Invoice.find_by(invoiced: reservation) + assert_invoice_pdf invoice + + # notification + assert_not_empty Notification.where(attached_object: reservation) + + # wallet + assert_equal @vlonchamp.wallet.amount, 0 + assert_equal @vlonchamp.wallet.wallet_transactions.count, 2 + transaction = @vlonchamp.wallet.wallet_transactions.last + assert_equal transaction.transaction_type, 'debit' + assert_equal transaction.amount, 10 + assert_equal transaction.amount, invoice.wallet_amount / 100.0 + assert_equal transaction.id, invoice.wallet_transaction_id + end + + test 'user without subscription reserves a machine and pay wallet with success' do + @vlonchamp = User.find_by(username: 'vlonchamp') + machine = Machine.find(6) + availability = machine.availabilities.first + + reservations_count = Reservation.count + invoice_count = Invoice.count + invoice_items_count = InvoiceItem.count + users_credit_count = UsersCredit.count + + post reservations_path, params: { reservation: { + user_id: @vlonchamp.id, + reservable_id: machine.id, + reservable_type: machine.class.name, + slots_attributes: [ + { + start_at: availability.start_at.to_s(:iso8601), + end_at: (availability.start_at + 1.hour).to_s(:iso8601), + availability_id: availability.id + } + ] + } }.to_json, headers: default_headers + + # general assertions + assert_equal 201, response.status + assert_equal reservations_count + 1, Reservation.count + assert_equal invoice_count + 1, Invoice.count + assert_equal invoice_items_count + 1, InvoiceItem.count + assert_equal users_credit_count, UsersCredit.count + + # subscription assertions + assert_equal 0, @vlonchamp.subscriptions.count + assert_nil @vlonchamp.subscribed_plan + + # reservation assertions + reservation = Reservation.last + + assert_not_nil reservation.invoice + + # notification + assert_not_empty Notification.where(attached_object: reservation) + end + + test 'user reserves a training and a subscription with success' do + training = Training.first + availability = training.availabilities.first + plan = Plan.where(group_id: @user_without_subscription.group.id, type: 'Plan').first + + reservations_count = Reservation.count + invoice_count = Invoice.count + invoice_items_count = InvoiceItem.count + users_credit_count = UsersCredit.count + + post reservations_path, params: { reservation: { + user_id: @user_without_subscription.id, + plan_id: plan.id, + reservable_id: training.id, + reservable_type: training.class.name, + slots_attributes: [ + { + start_at: availability.start_at.to_s(:iso8601), + end_at: (availability.start_at + 1.hour).to_s(:iso8601), + offered: false, + availability_id: availability.id + } + ] + } }.to_json, headers: default_headers + + # general assertions + assert_equal 201, response.status + assert_equal Mime[:json], response.content_type + result = json_response(response.body) + + # Check the DB objects have been created as they should + assert_equal reservations_count + 1, Reservation.count + assert_equal invoice_count + 1, Invoice.count + assert_equal invoice_items_count + 2, InvoiceItem.count + assert_equal users_credit_count + 1, UsersCredit.count + + # subscription assertions + assert_equal 1, @user_without_subscription.subscriptions.count + assert_not_nil @user_without_subscription.subscribed_plan + assert_equal plan.id, @user_without_subscription.subscribed_plan.id + + # reservation assertions + reservation = Reservation.find(result[:id]) + + assert reservation.invoice + assert_equal 2, reservation.invoice.invoice_items.count + + # credits assertions + assert_equal 1, @user_without_subscription.credits.count + assert_equal 'Training', @user_without_subscription.credits.last.creditable_type + assert_equal training.id, @user_without_subscription.credits.last.creditable_id + + # invoice assertions + invoice = reservation.invoice + + assert invoice.stp_payment_intent_id.blank? + refute invoice.total.blank? + assert_equal plan.amount, invoice.total + + # invoice_items + invoice_items = InvoiceItem.last(2) + + assert(invoice_items.any? { |ii| ii.amount == plan.amount && !ii.subscription_id.nil? }) + assert(invoice_items.any? { |ii| ii.amount.zero? }) + + # invoice assertions + invoice = Invoice.find_by(invoiced: reservation) + assert_invoice_pdf invoice + + # notification + assert_not_empty Notification.where(attached_object: reservation) + end + + test 'user reserves a training and a subscription with payment schedule' do + reservations_count = Reservation.count + invoice_count = Invoice.count + invoice_items_count = InvoiceItem.count + subscriptions_count = Subscription.count + users_credit_count = UsersCredit.count + payment_schedule_count = PaymentSchedule.count + payment_schedule_items_count = PaymentScheduleItem.count + + training = Training.find(1) + availability = training.availabilities.first + plan = Plan.find_by(group_id: @user_without_subscription.group.id, type: 'Plan', base_name: 'Abonnement mensualisable') + + VCR.use_cassette('reservations_admin_training_subscription_with_payment_schedule') do post reservations_path, params: { reservation: { user_id: @user_without_subscription.id, + payment_method: '', # pay by check reservable_id: training.id, reservable_type: training.class.name, slots_attributes: [ @@ -85,367 +474,31 @@ module Reservations end_at: (availability.start_at + 1.hour).to_s(:iso8601), availability_id: availability.id } - ] - } }.to_json, headers: default_headers - - # general assertions - assert_equal 201, response.status - assert_equal reservations_count + 1, Reservation.count - assert_equal invoice_count + 1, Invoice.count - assert_equal invoice_items_count + 1, InvoiceItem.count - - # subscription assertions - assert_equal 0, @user_without_subscription.subscriptions.count - assert_nil @user_without_subscription.subscribed_plan - - # reservation assertions - reservation = Reservation.last - - assert reservation.invoice - assert_equal 1, reservation.invoice.invoice_items.count - - # invoice assertions - invoice = reservation.invoice - - assert invoice.stp_payment_intent_id.blank? - refute invoice.total.blank? - # invoice_items - invoice_item = InvoiceItem.last - - assert_equal invoice_item.amount, training.amount_by_group(@user_without_subscription.group_id).amount - - # invoice assertions - invoice = Invoice.find_by(invoiced: reservation) - assert_invoice_pdf invoice - - # notification - assert_not_empty Notification.where(attached_object: reservation) - end - - test 'user with subscription reserves a machine with success' do - plan = @user_with_subscription.subscribed_plan - machine = Machine.find(6) - availability = machine.availabilities.first - - reservations_count = Reservation.count - invoice_count = Invoice.count - invoice_items_count = InvoiceItem.count - users_credit_count = UsersCredit.count - - post reservations_path, params: { reservation: { - user_id: @user_with_subscription.id, - reservable_id: machine.id, - reservable_type: machine.class.name, - slots_attributes: [ - { - start_at: availability.start_at.to_s(:iso8601), - end_at: (availability.start_at + 1.hour).to_s(:iso8601), - availability_id: availability.id - }, - { - start_at: (availability.start_at + 1.hour).to_s(:iso8601), - end_at: (availability.start_at + 2.hours).to_s(:iso8601), - availability_id: availability.id - } - ] - } }.to_json, headers: default_headers - - # general assertions - assert_equal 201, response.status - assert_equal reservations_count + 1, Reservation.count - assert_equal invoice_count + 1, Invoice.count - assert_equal invoice_items_count + 2, InvoiceItem.count - assert_equal users_credit_count + 1, UsersCredit.count - - # subscription assertions - assert_equal 1, @user_with_subscription.subscriptions.count - assert_not_nil @user_with_subscription.subscribed_plan - assert_equal plan.id, @user_with_subscription.subscribed_plan.id - - # reservation assertions - reservation = Reservation.last - - assert reservation.invoice - assert_equal 2, reservation.invoice.invoice_items.count - - # invoice assertions - invoice = reservation.invoice - - assert invoice.stp_payment_intent_id.blank? - refute invoice.total.blank? - - # invoice_items assertions - invoice_items = InvoiceItem.last(2) - machine_price = machine.prices.find_by(group_id: @user_with_subscription.group_id, plan_id: plan.id).amount - - assert(invoice_items.any? { |ii| ii.amount.zero? }) - assert(invoice_items.any? { |ii| ii.amount == machine_price }) - - # users_credits assertions - users_credit = UsersCredit.last - - assert_equal @user_with_subscription, users_credit.user - assert_equal [reservation.slots.count, plan.machine_credits.find_by(creditable_id: machine.id).hours].min, users_credit.hours_used - - # invoice assertions - invoice = Invoice.find_by(invoiced: reservation) - assert_invoice_pdf invoice - - # notification - assert_not_empty Notification.where(attached_object: reservation) - end - - test 'user without subscription reserves a machine and pay by wallet with success' do - @vlonchamp = User.find_by(username: 'vlonchamp') - machine = Machine.find(6) - availability = machine.availabilities.first - - reservations_count = Reservation.count - invoice_count = Invoice.count - invoice_items_count = InvoiceItem.count - users_credit_count = UsersCredit.count - - post reservations_path, params: { reservation: { - user_id: @vlonchamp.id, - reservable_id: machine.id, - reservable_type: machine.class.name, - slots_attributes: [ - { - start_at: availability.start_at.to_s(:iso8601), - end_at: (availability.start_at + 1.hour).to_s(:iso8601), - availability_id: availability.id - } - ] - } }.to_json, headers: default_headers - - # general assertions - assert_equal 201, response.status - assert_equal reservations_count + 1, Reservation.count - assert_equal invoice_count + 1, Invoice.count - assert_equal invoice_items_count + 1, InvoiceItem.count - assert_equal users_credit_count, UsersCredit.count - - # subscription assertions - assert_equal 0, @user_without_subscription.subscriptions.count - assert_nil @user_without_subscription.subscribed_plan - - # reservation assertions - reservation = Reservation.last - - assert reservation.invoice - assert_equal 1, reservation.invoice.invoice_items.count - - # invoice assertions - invoice = reservation.invoice - - assert invoice.stp_payment_intent_id.blank? - refute invoice.total.blank? - - # invoice_items assertions - invoice_item = InvoiceItem.last - - assert_equal machine.prices.find_by(group_id: @vlonchamp.group_id, plan_id: nil).amount, invoice_item.amount - - # invoice assertions - invoice = Invoice.find_by(invoiced: reservation) - assert_invoice_pdf invoice - - # notification - assert_not_empty Notification.where(attached_object: reservation) - - # wallet - assert_equal @vlonchamp.wallet.amount, 0 - assert_equal @vlonchamp.wallet.wallet_transactions.count, 2 - transaction = @vlonchamp.wallet.wallet_transactions.last - assert_equal transaction.transaction_type, 'debit' - assert_equal transaction.amount, 10 - assert_equal transaction.amount, invoice.wallet_amount / 100.0 - assert_equal transaction.id, invoice.wallet_transaction_id - end - - test 'user reserves a machine and a subscription pay by wallet with success' do - @vlonchamp = User.find_by(username: 'vlonchamp') - machine = Machine.find(6) - availability = machine.availabilities.first - plan = Plan.find_by(group_id: @vlonchamp.group.id, type: 'Plan', base_name: 'Mensuel tarif réduit') - - reservations_count = Reservation.count - invoice_count = Invoice.count - invoice_items_count = InvoiceItem.count - users_credit_count = UsersCredit.count - wallet_transactions_count = WalletTransaction.count - - post reservations_path, params: { reservation: { - user_id: @vlonchamp.id, - reservable_id: machine.id, - reservable_type: machine.class.name, + ], plan_id: plan.id, - slots_attributes: [ - { - start_at: availability.start_at.to_s(:iso8601), - end_at: (availability.start_at + 1.hour).to_s(:iso8601), - availability_id: availability.id - } - ] + payment_schedule: true } }.to_json, headers: default_headers - - # general assertions - assert_equal 201, response.status - assert_equal reservations_count + 1, Reservation.count - assert_equal invoice_count + 1, Invoice.count - assert_equal invoice_items_count + 2, InvoiceItem.count - assert_equal users_credit_count + 1, UsersCredit.count - assert_equal wallet_transactions_count + 1, WalletTransaction.count - - # subscription assertions - assert_equal 1, @vlonchamp.subscriptions.count - assert_not_nil @vlonchamp.subscribed_plan - assert_equal plan.id, @vlonchamp.subscribed_plan.id - - # reservation assertions - reservation = Reservation.last - - assert reservation.invoice - assert_equal 2, reservation.invoice.invoice_items.count - - # invoice assertions - invoice = reservation.invoice - - assert invoice.stp_payment_intent_id.blank? - refute invoice.total.blank? - assert_equal invoice.total, 2000 - - # invoice assertions - invoice = Invoice.find_by(invoiced: reservation) - assert_invoice_pdf invoice - - # notification - assert_not_empty Notification.where(attached_object: reservation) - - # wallet - assert_equal @vlonchamp.wallet.amount, 0 - assert_equal @vlonchamp.wallet.wallet_transactions.count, 2 - transaction = @vlonchamp.wallet.wallet_transactions.last - assert_equal transaction.transaction_type, 'debit' - assert_equal transaction.amount, 10 - assert_equal transaction.amount, invoice.wallet_amount / 100.0 - assert_equal transaction.id, invoice.wallet_transaction_id end - test 'user without subscription reserves a machine and pay wallet with success' do - @vlonchamp = User.find_by(username: 'vlonchamp') - machine = Machine.find(6) - availability = machine.availabilities.first + # Check response format & status + assert_equal 201, response.status, response.body + assert_equal Mime[:json], response.content_type + assert_equal reservations_count + 1, Reservation.count, 'missing the reservation' + assert_equal invoice_count, Invoice.count, "an invoice was generated but it shouldn't" + assert_equal invoice_items_count, InvoiceItem.count, "some invoice items were generated but they shouldn't" + assert_equal users_credit_count, UsersCredit.count, "user's credits count has changed but it shouldn't" + assert_equal subscriptions_count + 1, Subscription.count, 'missing the subscription' + assert_equal payment_schedule_count + 1, PaymentSchedule.count, 'missing the payment schedule' + assert_equal payment_schedule_items_count + 12, PaymentScheduleItem.count, 'missing some payment schedule items' - reservations_count = Reservation.count - invoice_count = Invoice.count - invoice_items_count = InvoiceItem.count - users_credit_count = UsersCredit.count + # subscription assertions + assert_equal 1, @user_without_subscription.subscriptions.count + assert_not_nil @user_without_subscription.subscribed_plan, "user's subscribed plan was not found" + assert_not_nil @user_without_subscription.subscription, "user's subscription was not found" + assert_equal plan.id, @user_without_subscription.subscribed_plan.id, "user's plan does not match" - post reservations_path, params: { reservation: { - user_id: @vlonchamp.id, - reservable_id: machine.id, - reservable_type: machine.class.name, - slots_attributes: [ - { - start_at: availability.start_at.to_s(:iso8601), - end_at: (availability.start_at + 1.hour).to_s(:iso8601), - availability_id: availability.id - } - ] - } }.to_json, headers: default_headers - - # general assertions - assert_equal 201, response.status - assert_equal reservations_count + 1, Reservation.count - assert_equal invoice_count + 1, Invoice.count - assert_equal invoice_items_count + 1, InvoiceItem.count - assert_equal users_credit_count, UsersCredit.count - - # subscription assertions - assert_equal 0, @vlonchamp.subscriptions.count - assert_nil @vlonchamp.subscribed_plan - - # reservation assertions - reservation = Reservation.last - - assert_not_nil reservation.invoice - - # notification - assert_not_empty Notification.where(attached_object: reservation) - end - - test 'user reserves a training and a subscription with success' do - training = Training.first - availability = training.availabilities.first - plan = Plan.where(group_id: @user_without_subscription.group.id, type: 'Plan').first - - reservations_count = Reservation.count - invoice_count = Invoice.count - invoice_items_count = InvoiceItem.count - users_credit_count = UsersCredit.count - - post reservations_path, params: { reservation: { - user_id: @user_without_subscription.id, - plan_id: plan.id, - reservable_id: training.id, - reservable_type: training.class.name, - slots_attributes: [ - { - start_at: availability.start_at.to_s(:iso8601), - end_at: (availability.start_at + 1.hour).to_s(:iso8601), - offered: false, - availability_id: availability.id - } - ] - } }.to_json, headers: default_headers - - # general assertions - assert_equal 201, response.status - assert_equal Mime[:json], response.content_type - result = json_response(response.body) - - # Check the DB objects have been created as they should - assert_equal reservations_count + 1, Reservation.count - assert_equal invoice_count + 1, Invoice.count - assert_equal invoice_items_count + 2, InvoiceItem.count - assert_equal users_credit_count + 1, UsersCredit.count - - # subscription assertions - assert_equal 1, @user_without_subscription.subscriptions.count - assert_not_nil @user_without_subscription.subscribed_plan - assert_equal plan.id, @user_without_subscription.subscribed_plan.id - - # reservation assertions - reservation = Reservation.find(result[:id]) - - assert reservation.invoice - assert_equal 2, reservation.invoice.invoice_items.count - - # credits assertions - assert_equal 1, @user_without_subscription.credits.count - assert_equal 'Training', @user_without_subscription.credits.last.creditable_type - assert_equal training.id, @user_without_subscription.credits.last.creditable_id - - # invoice assertions - invoice = reservation.invoice - - assert invoice.stp_payment_intent_id.blank? - refute invoice.total.blank? - assert_equal plan.amount, invoice.total - - # invoice_items - invoice_items = InvoiceItem.last(2) - - assert(invoice_items.any? { |ii| ii.amount == plan.amount && !ii.subscription_id.nil? }) - assert(invoice_items.any? { |ii| ii.amount.zero? }) - - # invoice assertions - invoice = Invoice.find_by(invoiced: reservation) - assert_invoice_pdf invoice - - # notification - assert_not_empty Notification.where(attached_object: reservation) - end + # Check the answer + reservation = json_response(response.body) + assert_equal plan.id, reservation[:user][:subscribed_plan][:id], 'subscribed plan does not match' end end diff --git a/test/integration/reservations/create_test.rb b/test/integration/reservations/create_test.rb index e0ff756e4..1383d112b 100644 --- a/test/integration/reservations/create_test.rb +++ b/test/integration/reservations/create_test.rb @@ -2,299 +2,147 @@ require 'test_helper' -module Reservations - class CreateTest < ActionDispatch::IntegrationTest - setup do - @user_without_subscription = User.members.without_subscription.first - @user_with_subscription = User.members.with_subscription.second - end +class Reservations::CreateTest < ActionDispatch::IntegrationTest + setup do + @user_without_subscription = User.members.without_subscription.first + @user_with_subscription = User.members.with_subscription.second + end - test 'user without subscription reserves a machine with success' do - login_as(@user_without_subscription, scope: :user) + test 'user without subscription reserves a machine with success' do + login_as(@user_without_subscription, scope: :user) - machine = Machine.find(6) - availability = machine.availabilities.first + machine = Machine.find(6) + availability = machine.availabilities.first - reservations_count = Reservation.count - invoice_count = Invoice.count - invoice_items_count = InvoiceItem.count - users_credit_count = UsersCredit.count - subscriptions_count = Subscription.count + reservations_count = Reservation.count + invoice_count = Invoice.count + invoice_items_count = InvoiceItem.count + users_credit_count = UsersCredit.count + subscriptions_count = Subscription.count - VCR.use_cassette('reservations_create_for_machine_without_subscription_success') do - post '/api/payments/confirm_payment', - params: { - payment_method_id: stripe_payment_method, - cart_items: { - reservation: { - reservable_id: machine.id, - reservable_type: machine.class.name, - slots_attributes: [ - { - start_at: availability.start_at.to_s(:iso8601), - end_at: (availability.start_at + 1.hour).to_s(:iso8601), - availability_id: availability.id - } - ] - } + VCR.use_cassette('reservations_create_for_machine_without_subscription_success') do + post '/api/payments/confirm_payment', + params: { + payment_method_id: stripe_payment_method, + cart_items: { + reservation: { + reservable_id: machine.id, + reservable_type: machine.class.name, + slots_attributes: [ + { + start_at: availability.start_at.to_s(:iso8601), + end_at: (availability.start_at + 1.hour).to_s(:iso8601), + availability_id: availability.id + } + ] } - }.to_json, headers: default_headers - end - - # general assertions - assert_equal 201, response.status - assert_equal reservations_count + 1, Reservation.count - assert_equal invoice_count + 1, Invoice.count - assert_equal invoice_items_count + 1, InvoiceItem.count - assert_equal users_credit_count, UsersCredit.count - assert_equal subscriptions_count, Subscription.count - - # subscription assertions - assert_equal 0, @user_without_subscription.subscriptions.count - assert_nil @user_without_subscription.subscribed_plan - - # reservation assertions - reservation = Reservation.last - - assert reservation.invoice - assert_equal 1, reservation.invoice.invoice_items.count - - # invoice assertions - invoice = reservation.invoice - - refute invoice.stp_payment_intent_id.blank? - refute invoice.total.blank? - assert invoice.check_footprint - - # invoice_items assertions - invoice_item = InvoiceItem.last - - assert_equal machine.prices.find_by(group_id: @user_without_subscription.group_id, plan_id: nil).amount, invoice_item.amount - assert invoice_item.check_footprint - - # invoice assertions - invoice = Invoice.find_by(invoiced: reservation) - assert_invoice_pdf invoice - - # notification - assert_not_empty Notification.where(attached_object: reservation) + } + }.to_json, headers: default_headers end - test 'user without subscription reserves a machine with error' do - login_as(@user_without_subscription, scope: :user) + # general assertions + assert_equal 201, response.status + assert_equal reservations_count + 1, Reservation.count + assert_equal invoice_count + 1, Invoice.count + assert_equal invoice_items_count + 1, InvoiceItem.count + assert_equal users_credit_count, UsersCredit.count + assert_equal subscriptions_count, Subscription.count - machine = Machine.find(6) - availability = machine.availabilities.first + # subscription assertions + assert_equal 0, @user_without_subscription.subscriptions.count + assert_nil @user_without_subscription.subscribed_plan - reservations_count = Reservation.count - invoice_count = Invoice.count - invoice_items_count = InvoiceItem.count - notifications_count = Notification.count + # reservation assertions + reservation = Reservation.last - VCR.use_cassette('reservations_create_for_machine_without_subscription_error') do - post '/api/payments/confirm_payment', - params: { - payment_method_id: stripe_payment_method(error: :card_declined), - cart_items: { - reservation: { - reservable_id: machine.id, - reservable_type: machine.class.name, - slots_attributes: [ - { - start_at: availability.start_at.to_s(:iso8601), - end_at: (availability.start_at + 1.hour).to_s(:iso8601), - availability_id: availability.id - } - ] - } + assert reservation.invoice + assert_equal 1, reservation.invoice.invoice_items.count + + # invoice assertions + invoice = reservation.invoice + + refute invoice.stp_payment_intent_id.blank? + refute invoice.total.blank? + assert invoice.check_footprint + + # invoice_items assertions + invoice_item = InvoiceItem.last + + assert_equal machine.prices.find_by(group_id: @user_without_subscription.group_id, plan_id: nil).amount, invoice_item.amount + assert invoice_item.check_footprint + + # invoice assertions + invoice = Invoice.find_by(invoiced: reservation) + assert_invoice_pdf invoice + + # notification + assert_not_empty Notification.where(attached_object: reservation) + end + + test 'user without subscription reserves a machine with error' do + login_as(@user_without_subscription, scope: :user) + + machine = Machine.find(6) + availability = machine.availabilities.first + + reservations_count = Reservation.count + invoice_count = Invoice.count + invoice_items_count = InvoiceItem.count + notifications_count = Notification.count + + VCR.use_cassette('reservations_create_for_machine_without_subscription_error') do + post '/api/payments/confirm_payment', + params: { + payment_method_id: stripe_payment_method(error: :card_declined), + cart_items: { + reservation: { + reservable_id: machine.id, + reservable_type: machine.class.name, + slots_attributes: [ + { + start_at: availability.start_at.to_s(:iso8601), + end_at: (availability.start_at + 1.hour).to_s(:iso8601), + availability_id: availability.id + } + ] } - }.to_json, headers: default_headers - end - - # Check response format & status - assert_equal 200, response.status, "API does not return the expected status. #{response.body}" - assert_equal Mime[:json], response.content_type - - # Check the error was handled - assert_match /Your card was declined/, response.body - - # Check the subscription wasn't taken - assert_equal reservations_count, Reservation.count - assert_equal invoice_count, Invoice.count - assert_equal invoice_items_count, InvoiceItem.count - assert_equal notifications_count, Notification.count - - # subscription assertions - assert_equal 0, @user_without_subscription.subscriptions.count - assert_nil @user_without_subscription.subscribed_plan + } + }.to_json, headers: default_headers end - test 'user without subscription reserves a training with success' do - login_as(@user_without_subscription, scope: :user) + # Check response format & status + assert_equal 200, response.status, "API does not return the expected status. #{response.body}" + assert_equal Mime[:json], response.content_type - training = Training.first - availability = training.availabilities.first + # Check the error was handled + assert_match /Your card was declined/, response.body - reservations_count = Reservation.count - invoice_count = Invoice.count - invoice_items_count = InvoiceItem.count + # Check the subscription wasn't taken + assert_equal reservations_count, Reservation.count + assert_equal invoice_count, Invoice.count + assert_equal invoice_items_count, InvoiceItem.count + assert_equal notifications_count, Notification.count - VCR.use_cassette('reservations_create_for_training_without_subscription_success') do - post '/api/payments/confirm_payment', - params: { - payment_method_id: stripe_payment_method, - cart_items: { - reservation: { - reservable_id: training.id, - reservable_type: training.class.name, - slots_attributes: [ - { - start_at: availability.start_at.to_s(:iso8601), - end_at: availability.end_at.to_s(:iso8601), - availability_id: availability.id - } - ] - } - } - }.to_json, headers: default_headers - end + # subscription assertions + assert_equal 0, @user_without_subscription.subscriptions.count + assert_nil @user_without_subscription.subscribed_plan + end - # general assertions - assert_equal 201, response.status - assert_equal reservations_count + 1, Reservation.count - assert_equal invoice_count + 1, Invoice.count - assert_equal invoice_items_count + 1, InvoiceItem.count + test 'user without subscription reserves a training with success' do + login_as(@user_without_subscription, scope: :user) - # subscription assertions - assert_equal 0, @user_without_subscription.subscriptions.count - assert_nil @user_without_subscription.subscribed_plan + training = Training.first + availability = training.availabilities.first - # reservation assertions - reservation = Reservation.last + reservations_count = Reservation.count + invoice_count = Invoice.count + invoice_items_count = InvoiceItem.count - assert reservation.invoice - assert_equal 1, reservation.invoice.invoice_items.count - - # invoice assertions - invoice = reservation.invoice - - refute invoice.stp_payment_intent_id.blank? - refute invoice.total.blank? - assert invoice.check_footprint - - # invoice_items - invoice_item = InvoiceItem.last - - assert_equal invoice_item.amount, training.amount_by_group(@user_without_subscription.group_id).amount - assert invoice_item.check_footprint - - # invoice assertions - invoice = Invoice.find_by(invoiced: reservation) - assert_invoice_pdf invoice - - # notification - assert_not_empty Notification.where(attached_object: reservation) - end - - test 'user with subscription reserves a machine with success' do - login_as(@user_with_subscription, scope: :user) - - plan = @user_with_subscription.subscribed_plan - machine = Machine.find(6) - availability = machine.availabilities.first - - reservations_count = Reservation.count - invoice_count = Invoice.count - invoice_items_count = InvoiceItem.count - users_credit_count = UsersCredit.count - - VCR.use_cassette('reservations_create_for_machine_with_subscription_success') do - post '/api/payments/confirm_payment', - params: { - payment_method_id: stripe_payment_method, - cart_items: { - reservation: { - reservable_id: machine.id, - reservable_type: machine.class.name, - slots_attributes: [ - { - start_at: availability.start_at.to_s(:iso8601), - end_at: (availability.start_at + 1.hour).to_s(:iso8601), - availability_id: availability.id - }, - { - start_at: (availability.start_at + 1.hour).to_s(:iso8601), - end_at: (availability.start_at + 2.hours).to_s(:iso8601), - availability_id: availability.id - } - ] - } - } - }.to_json, headers: default_headers - end - - # general assertions - assert_equal 201, response.status - assert_equal reservations_count + 1, Reservation.count - assert_equal invoice_count + 1, Invoice.count - assert_equal invoice_items_count + 2, InvoiceItem.count - assert_equal users_credit_count + 1, UsersCredit.count - - # subscription assertions - assert_equal 1, @user_with_subscription.subscriptions.count - assert_not_nil @user_with_subscription.subscribed_plan - assert_equal plan.id, @user_with_subscription.subscribed_plan.id - - # reservation assertions - reservation = Reservation.last - - assert reservation.invoice - assert_equal 2, reservation.invoice.invoice_items.count - - # invoice assertions - invoice = reservation.invoice - - refute invoice.stp_payment_intent_id.blank? - refute invoice.total.blank? - assert invoice.check_footprint - - # invoice_items assertions - invoice_items = InvoiceItem.last(2) - machine_price = machine.prices.find_by(group_id: @user_with_subscription.group_id, plan_id: plan.id).amount - - assert(invoice_items.any? { |inv| inv.amount.zero? }) - assert(invoice_items.any? { |inv| inv.amount == machine_price }) - assert(invoice_items.all?(&:check_footprint)) - - # users_credits assertions - users_credit = UsersCredit.last - - assert_equal @user_with_subscription, users_credit.user - assert_equal [reservation.slots.count, plan.machine_credits.find_by(creditable_id: machine.id).hours].min, users_credit.hours_used - - # invoice assertions - invoice = Invoice.find_by(invoiced: reservation) - assert_invoice_pdf invoice - - # notification - assert_not_empty Notification.where(attached_object: reservation) - end - - test 'user with subscription reserves the FIRST training with success' do - login_as(@user_with_subscription, scope: :user) - plan = @user_with_subscription.subscribed_plan - plan.update!(is_rolling: true) - - training = Training.joins(credits: :plan).where(credits: { plan: plan }).first - availability = training.availabilities.first - - reservations_count = Reservation.count - invoice_count = Invoice.count - invoice_items_count = InvoiceItem.count - - VCR.use_cassette('reservations_create_for_training_with_subscription_success') do - post '/api/reservations', - params: { + VCR.use_cassette('reservations_create_for_training_without_subscription_success') do + post '/api/payments/confirm_payment', + params: { + payment_method_id: stripe_payment_method, + cart_items: { reservation: { reservable_id: training.id, reservable_type: training.class.name, @@ -306,434 +154,584 @@ module Reservations } ] } - }.to_json, headers: default_headers - end - - # general assertions - assert_equal 201, response.status - assert_equal reservations_count + 1, Reservation.count - assert_equal invoice_count + 1, Invoice.count - assert_equal invoice_items_count + 1, InvoiceItem.count - - # subscription assertions - assert_equal 1, @user_with_subscription.subscriptions.count - assert_not_nil @user_with_subscription.subscribed_plan - assert_equal plan.id, @user_with_subscription.subscribed_plan.id - - # reservation assertions - reservation = Reservation.last - - assert reservation.invoice - assert_equal 1, reservation.invoice.invoice_items.count - - # invoice assertions - invoice = reservation.invoice - - assert invoice.stp_payment_intent_id.blank? - refute invoice.total.blank? - assert invoice.check_footprint - - # invoice_items - invoice_item = InvoiceItem.last - - assert_equal 0, invoice_item.amount # amount is 0 because this training is a credited training with that plan - assert invoice_item.check_footprint - - # invoice assertions - invoice = Invoice.find_by(invoiced: reservation) - assert_invoice_pdf invoice - - # notification - assert_not_empty Notification.where(attached_object: reservation) - - # check that user subscription were extended - assert_equal reservation.slots.first.start_at + plan.duration, @user_with_subscription.subscription.expired_at + } + }.to_json, headers: default_headers end - test 'user reserves a machine and pay by wallet with success' do - @vlonchamp = User.find_by(username: 'vlonchamp') - login_as(@vlonchamp, scope: :user) + # general assertions + assert_equal 201, response.status + assert_equal reservations_count + 1, Reservation.count + assert_equal invoice_count + 1, Invoice.count + assert_equal invoice_items_count + 1, InvoiceItem.count - machine = Machine.find(6) - availability = machine.availabilities.first + # subscription assertions + assert_equal 0, @user_without_subscription.subscriptions.count + assert_nil @user_without_subscription.subscribed_plan - reservations_count = Reservation.count - invoice_count = Invoice.count - invoice_items_count = InvoiceItem.count - users_credit_count = UsersCredit.count - wallet_transactions_count = WalletTransaction.count + # reservation assertions + reservation = Reservation.last - VCR.use_cassette('reservations_create_for_machine_and_pay_wallet_success') do - post '/api/payments/confirm_payment', - params: { - payment_method_id: stripe_payment_method, - cart_items: { - reservation: { - user_id: @vlonchamp.id, - reservable_id: machine.id, - reservable_type: machine.class.name, - card_token: stripe_payment_method, - slots_attributes: [ - { - start_at: availability.start_at.to_s(:iso8601), - end_at: (availability.start_at + 1.hour).to_s(:iso8601), - availability_id: availability.id - } - ] + assert reservation.invoice + assert_equal 1, reservation.invoice.invoice_items.count + + # invoice assertions + invoice = reservation.invoice + + refute invoice.stp_payment_intent_id.blank? + refute invoice.total.blank? + assert invoice.check_footprint + + # invoice_items + invoice_item = InvoiceItem.last + + assert_equal invoice_item.amount, training.amount_by_group(@user_without_subscription.group_id).amount + assert invoice_item.check_footprint + + # invoice assertions + invoice = Invoice.find_by(invoiced: reservation) + assert_invoice_pdf invoice + + # notification + assert_not_empty Notification.where(attached_object: reservation) + end + + test 'user with subscription reserves a machine with success' do + login_as(@user_with_subscription, scope: :user) + + plan = @user_with_subscription.subscribed_plan + machine = Machine.find(6) + availability = machine.availabilities.first + + reservations_count = Reservation.count + invoice_count = Invoice.count + invoice_items_count = InvoiceItem.count + users_credit_count = UsersCredit.count + + VCR.use_cassette('reservations_create_for_machine_with_subscription_success') do + post '/api/payments/confirm_payment', + params: { + payment_method_id: stripe_payment_method, + cart_items: { + reservation: { + reservable_id: machine.id, + reservable_type: machine.class.name, + slots_attributes: [ + { + start_at: availability.start_at.to_s(:iso8601), + end_at: (availability.start_at + 1.hour).to_s(:iso8601), + availability_id: availability.id + }, + { + start_at: (availability.start_at + 1.hour).to_s(:iso8601), + end_at: (availability.start_at + 2.hours).to_s(:iso8601), + availability_id: availability.id + } + ] + } + } + }.to_json, headers: default_headers + end + + # general assertions + assert_equal 201, response.status + assert_equal reservations_count + 1, Reservation.count + assert_equal invoice_count + 1, Invoice.count + assert_equal invoice_items_count + 2, InvoiceItem.count + assert_equal users_credit_count + 1, UsersCredit.count + + # subscription assertions + assert_equal 1, @user_with_subscription.subscriptions.count + assert_not_nil @user_with_subscription.subscribed_plan + assert_equal plan.id, @user_with_subscription.subscribed_plan.id + + # reservation assertions + reservation = Reservation.last + + assert reservation.invoice + assert_equal 2, reservation.invoice.invoice_items.count + + # invoice assertions + invoice = reservation.invoice + + refute invoice.stp_payment_intent_id.blank? + refute invoice.total.blank? + assert invoice.check_footprint + + # invoice_items assertions + invoice_items = InvoiceItem.last(2) + machine_price = machine.prices.find_by(group_id: @user_with_subscription.group_id, plan_id: plan.id).amount + + assert(invoice_items.any? { |inv| inv.amount.zero? }) + assert(invoice_items.any? { |inv| inv.amount == machine_price }) + assert(invoice_items.all?(&:check_footprint)) + + # users_credits assertions + users_credit = UsersCredit.last + + assert_equal @user_with_subscription, users_credit.user + assert_equal [reservation.slots.count, plan.machine_credits.find_by(creditable_id: machine.id).hours].min, users_credit.hours_used + + # invoice assertions + invoice = Invoice.find_by(invoiced: reservation) + assert_invoice_pdf invoice + + # notification + assert_not_empty Notification.where(attached_object: reservation) + end + + test 'user with subscription reserves the FIRST training with success' do + login_as(@user_with_subscription, scope: :user) + plan = @user_with_subscription.subscribed_plan + plan.update!(is_rolling: true) + + training = Training.joins(credits: :plan).where(credits: { plan: plan }).first + availability = training.availabilities.first + + reservations_count = Reservation.count + invoice_count = Invoice.count + invoice_items_count = InvoiceItem.count + + VCR.use_cassette('reservations_create_for_training_with_subscription_success') do + post '/api/reservations', + params: { + reservation: { + reservable_id: training.id, + reservable_type: training.class.name, + slots_attributes: [ + { + start_at: availability.start_at.to_s(:iso8601), + end_at: availability.end_at.to_s(:iso8601), + availability_id: availability.id } - } - }.to_json, headers: default_headers - end - - @vlonchamp.wallet.reload - - # general assertions - assert_equal 201, response.status - assert_equal reservations_count + 1, Reservation.count - assert_equal invoice_count + 1, Invoice.count - assert_equal invoice_items_count + 1, InvoiceItem.count - assert_equal users_credit_count, UsersCredit.count - assert_equal wallet_transactions_count + 1, WalletTransaction.count - - # subscription assertions - assert_equal 0, @vlonchamp.subscriptions.count - assert_nil @vlonchamp.subscribed_plan - - # reservation assertions - reservation = Reservation.last - - assert reservation.invoice - assert_equal 1, reservation.invoice.invoice_items.count - - # invoice assertions - invoice = reservation.invoice - - refute invoice.stp_payment_intent_id.blank? - refute invoice.total.blank? - assert invoice.check_footprint - - # invoice_items assertions - invoice_item = InvoiceItem.last - - assert_equal machine.prices.find_by(group_id: @vlonchamp.group_id, plan_id: nil).amount, invoice_item.amount - assert invoice_item.check_footprint - - # invoice assertions - invoice = Invoice.find_by(invoiced: reservation) - assert_invoice_pdf invoice - - # notification - assert_not_empty Notification.where(attached_object: reservation) - - # wallet - assert_equal 0, @vlonchamp.wallet.amount - assert_equal 2, @vlonchamp.wallet.wallet_transactions.count - transaction = @vlonchamp.wallet.wallet_transactions.last - assert_equal 'debit', transaction.transaction_type - assert_equal 10, transaction.amount - assert_equal invoice.wallet_amount / 100.0, transaction.amount + ] + } + }.to_json, headers: default_headers end - test 'user reserves a training and a subscription by wallet with success' do - @vlonchamp = User.find_by(username: 'vlonchamp') - login_as(@vlonchamp, scope: :user) + # general assertions + assert_equal 201, response.status + assert_equal reservations_count + 1, Reservation.count + assert_equal invoice_count + 1, Invoice.count + assert_equal invoice_items_count + 1, InvoiceItem.count - training = Training.first - availability = training.availabilities.first - plan = Plan.find_by(group_id: @vlonchamp.group.id, type: 'Plan', base_name: 'Mensuel tarif réduit') + # subscription assertions + assert_equal 1, @user_with_subscription.subscriptions.count + assert_not_nil @user_with_subscription.subscribed_plan + assert_equal plan.id, @user_with_subscription.subscribed_plan.id - reservations_count = Reservation.count - invoice_count = Invoice.count - invoice_items_count = InvoiceItem.count - wallet_transactions_count = WalletTransaction.count + # reservation assertions + reservation = Reservation.last - VCR.use_cassette('reservations_create_for_training_and_plan_by_pay_wallet_success') do - post '/api/payments/confirm_payment', - params: { - payment_method_id: stripe_payment_method, - cart_items: { - reservation: { - reservable_id: training.id, - reservable_type: training.class.name, - plan_id: plan.id, - slots_attributes: [ - { - start_at: availability.start_at.to_s(:iso8601), - end_at: availability.end_at.to_s(:iso8601), - availability_id: availability.id - } - ] - } + assert reservation.invoice + assert_equal 1, reservation.invoice.invoice_items.count + + # invoice assertions + invoice = reservation.invoice + + assert invoice.stp_payment_intent_id.blank? + refute invoice.total.blank? + assert invoice.check_footprint + + # invoice_items + invoice_item = InvoiceItem.last + + assert_equal 0, invoice_item.amount # amount is 0 because this training is a credited training with that plan + assert invoice_item.check_footprint + + # invoice assertions + invoice = Invoice.find_by(invoiced: reservation) + assert_invoice_pdf invoice + + # notification + assert_not_empty Notification.where(attached_object: reservation) + + # check that user subscription were extended + assert_equal reservation.slots.first.start_at + plan.duration, @user_with_subscription.subscription.expired_at + end + + test 'user reserves a machine and pay by wallet with success' do + @vlonchamp = User.find_by(username: 'vlonchamp') + login_as(@vlonchamp, scope: :user) + + machine = Machine.find(6) + availability = machine.availabilities.first + + reservations_count = Reservation.count + invoice_count = Invoice.count + invoice_items_count = InvoiceItem.count + users_credit_count = UsersCredit.count + wallet_transactions_count = WalletTransaction.count + + VCR.use_cassette('reservations_create_for_machine_and_pay_wallet_success') do + post '/api/payments/confirm_payment', + params: { + payment_method_id: stripe_payment_method, + cart_items: { + reservation: { + user_id: @vlonchamp.id, + reservable_id: machine.id, + reservable_type: machine.class.name, + card_token: stripe_payment_method, + slots_attributes: [ + { + start_at: availability.start_at.to_s(:iso8601), + end_at: (availability.start_at + 1.hour).to_s(:iso8601), + availability_id: availability.id + } + ] } - }.to_json, headers: default_headers - end - - @vlonchamp.wallet.reload - - # general assertions - assert_equal 201, response.status - assert_equal reservations_count + 1, Reservation.count - assert_equal invoice_count + 1, Invoice.count - assert_equal invoice_items_count + 2, InvoiceItem.count - assert_equal wallet_transactions_count + 1, WalletTransaction.count - - # subscription assertions - assert_equal 1, @vlonchamp.subscriptions.count - assert_not_nil @vlonchamp.subscribed_plan - assert_equal plan.id, @vlonchamp.subscribed_plan.id - - # reservation assertions - reservation = Reservation.last - - assert reservation.invoice - assert_equal 2, reservation.invoice.invoice_items.count - - # invoice assertions - invoice = reservation.invoice - - refute invoice.stp_payment_intent_id.blank? - refute invoice.total.blank? - assert_equal invoice.total, 2000 - assert invoice.check_footprint - - # invoice assertions - invoice = Invoice.find_by(invoiced: reservation) - assert_invoice_pdf invoice - - # notification - assert_not_empty Notification.where(attached_object: reservation) - - # wallet - assert_equal 0, @vlonchamp.wallet.amount - assert_equal 2, @vlonchamp.wallet.wallet_transactions.count - transaction = @vlonchamp.wallet.wallet_transactions.last - assert_equal 'debit', transaction.transaction_type - assert_equal 10, transaction.amount - assert_equal invoice.wallet_amount / 100.0, transaction.amount + } + }.to_json, headers: default_headers end - test 'user reserves a machine and a subscription using a coupon with success' do - login_as(@user_without_subscription, scope: :user) + @vlonchamp.wallet.reload - machine = Machine.find(6) - plan = Plan.where(group_id: @user_without_subscription.group_id).first - availability = machine.availabilities.first + # general assertions + assert_equal 201, response.status + assert_equal reservations_count + 1, Reservation.count + assert_equal invoice_count + 1, Invoice.count + assert_equal invoice_items_count + 1, InvoiceItem.count + assert_equal users_credit_count, UsersCredit.count + assert_equal wallet_transactions_count + 1, WalletTransaction.count - reservations_count = Reservation.count - invoice_count = Invoice.count - invoice_items_count = InvoiceItem.count - subscriptions_count = Subscription.count - users_credit_count = UsersCredit.count + # subscription assertions + assert_equal 0, @vlonchamp.subscriptions.count + assert_nil @vlonchamp.subscribed_plan - VCR.use_cassette('reservations_machine_and_plan_using_coupon_success') do - post '/api/payments/confirm_payment', - params: { - payment_method_id: stripe_payment_method, - cart_items: { - reservation: { - reservable_id: machine.id, - reservable_type: machine.class.name, - slots_attributes: [ - { - start_at: availability.start_at.to_s(:iso8601), - end_at: (availability.start_at + 1.hour).to_s(:iso8601), - availability_id: availability.id - } - ], - plan_id: plan.id - }, - coupon_code: 'SUNNYFABLAB' + # reservation assertions + reservation = Reservation.last + + assert reservation.invoice + assert_equal 1, reservation.invoice.invoice_items.count + + # invoice assertions + invoice = reservation.invoice + + refute invoice.stp_payment_intent_id.blank? + refute invoice.total.blank? + assert invoice.check_footprint + + # invoice_items assertions + invoice_item = InvoiceItem.last + + assert_equal machine.prices.find_by(group_id: @vlonchamp.group_id, plan_id: nil).amount, invoice_item.amount + assert invoice_item.check_footprint + + # invoice assertions + invoice = Invoice.find_by(invoiced: reservation) + assert_invoice_pdf invoice + + # notification + assert_not_empty Notification.where(attached_object: reservation) + + # wallet + assert_equal 0, @vlonchamp.wallet.amount + assert_equal 2, @vlonchamp.wallet.wallet_transactions.count + transaction = @vlonchamp.wallet.wallet_transactions.last + assert_equal 'debit', transaction.transaction_type + assert_equal 10, transaction.amount + assert_equal invoice.wallet_amount / 100.0, transaction.amount + end + + test 'user reserves a training and a subscription by wallet with success' do + @vlonchamp = User.find_by(username: 'vlonchamp') + login_as(@vlonchamp, scope: :user) + + training = Training.first + availability = training.availabilities.first + plan = Plan.find_by(group_id: @vlonchamp.group.id, type: 'Plan', base_name: 'Mensuel tarif réduit') + + reservations_count = Reservation.count + invoice_count = Invoice.count + invoice_items_count = InvoiceItem.count + wallet_transactions_count = WalletTransaction.count + + VCR.use_cassette('reservations_create_for_training_and_plan_by_pay_wallet_success') do + post '/api/payments/confirm_payment', + params: { + payment_method_id: stripe_payment_method, + cart_items: { + reservation: { + reservable_id: training.id, + reservable_type: training.class.name, + plan_id: plan.id, + slots_attributes: [ + { + start_at: availability.start_at.to_s(:iso8601), + end_at: availability.end_at.to_s(:iso8601), + availability_id: availability.id + } + ] } - }.to_json, headers: default_headers - end - - # general assertions - assert_equal 201, response.status - assert_equal reservations_count + 1, Reservation.count - assert_equal invoice_count + 1, Invoice.count - assert_equal invoice_items_count + 2, InvoiceItem.count - assert_equal users_credit_count, UsersCredit.count - assert_equal subscriptions_count + 1, Subscription.count - - # subscription assertions - assert_equal 1, @user_without_subscription.subscriptions.count - assert_not_nil @user_without_subscription.subscribed_plan - assert_equal plan.id, @user_without_subscription.subscribed_plan.id - - # reservation assertions - reservation = Reservation.last - - assert reservation.invoice - assert_equal 2, reservation.invoice.invoice_items.count - - # invoice assertions - invoice = reservation.invoice - - refute invoice.stp_payment_intent_id.blank? - refute invoice.total.blank? - assert invoice.check_footprint - - # invoice_items assertions - ## reservation - reservation_item = invoice.invoice_items.where(subscription_id: nil).first - - assert_not_nil reservation_item - assert_equal reservation_item.amount, machine.prices.find_by(group_id: @user_without_subscription.group_id, plan_id: plan.id).amount - assert reservation_item.check_footprint - ## subscription - subscription_item = invoice.invoice_items.where.not(subscription_id: nil).first - - assert_not_nil subscription_item - - subscription = Subscription.find(subscription_item.subscription_id) - - assert_equal subscription_item.amount, plan.amount - assert_equal subscription.plan_id, plan.id - assert subscription_item.check_footprint - - # invoice assertions - invoice = Invoice.find_by(invoiced: reservation) - assert_invoice_pdf invoice - - VCR.use_cassette('reservations_machine_and_plan_using_coupon_retrieve_invoice_from_stripe') do - stp_intent = Stripe::PaymentIntent.retrieve(invoice.stp_payment_intent_id, api_key: Setting.get('stripe_secret_key')) - assert_equal stp_intent.amount, invoice.total - end - - # notifications - assert_not_empty Notification.where(attached_object: reservation) - assert_not_empty Notification.where(attached_object: subscription) + } + }.to_json, headers: default_headers end - test 'user reserves a training with an expired coupon with error' do - login_as(@user_without_subscription, scope: :user) + @vlonchamp.wallet.reload - training = Training.find(1) - availability = training.availabilities.first + # general assertions + assert_equal 201, response.status + assert_equal reservations_count + 1, Reservation.count + assert_equal invoice_count + 1, Invoice.count + assert_equal invoice_items_count + 2, InvoiceItem.count + assert_equal wallet_transactions_count + 1, WalletTransaction.count - reservations_count = Reservation.count - invoice_count = Invoice.count - invoice_items_count = InvoiceItem.count - notifications_count = Notification.count + # subscription assertions + assert_equal 1, @vlonchamp.subscriptions.count + assert_not_nil @vlonchamp.subscribed_plan + assert_equal plan.id, @vlonchamp.subscribed_plan.id - VCR.use_cassette('reservations_training_with_expired_coupon_error') do - post '/api/payments/confirm_payment', - params: { - payment_method_id: stripe_payment_method, - cart_items: { - reservation: { - user_id: @user_without_subscription.id, - reservable_id: training.id, - reservable_type: training.class.name, - card_token: stripe_payment_method, - slots_attributes: [ - { - start_at: availability.start_at.to_s(:iso8601), - end_at: (availability.start_at + 1.hour).to_s(:iso8601), - availability_id: availability.id - } - ] - }, - coupon_code: 'XMAS10' - } - }.to_json, headers: default_headers - end + # reservation assertions + reservation = Reservation.last - # general assertions - assert_equal 422, response.status - assert_equal reservations_count, Reservation.count - assert_equal invoice_count, Invoice.count - assert_equal invoice_items_count, InvoiceItem.count - assert_equal notifications_count, Notification.count + assert reservation.invoice + assert_equal 2, reservation.invoice.invoice_items.count - # subscription assertions - assert_equal 0, @user_without_subscription.subscriptions.count - assert_nil @user_without_subscription.subscribed_plan + # invoice assertions + invoice = reservation.invoice + + refute invoice.stp_payment_intent_id.blank? + refute invoice.total.blank? + assert_equal invoice.total, 2000 + assert invoice.check_footprint + + # invoice assertions + invoice = Invoice.find_by(invoiced: reservation) + assert_invoice_pdf invoice + + # notification + assert_not_empty Notification.where(attached_object: reservation) + + # wallet + assert_equal 0, @vlonchamp.wallet.amount + assert_equal 2, @vlonchamp.wallet.wallet_transactions.count + transaction = @vlonchamp.wallet.wallet_transactions.last + assert_equal 'debit', transaction.transaction_type + assert_equal 10, transaction.amount + assert_equal invoice.wallet_amount / 100.0, transaction.amount + end + + test 'user reserves a machine and a subscription using a coupon with success' do + login_as(@user_without_subscription, scope: :user) + + machine = Machine.find(6) + plan = Plan.where(group_id: @user_without_subscription.group_id).first + availability = machine.availabilities.first + + reservations_count = Reservation.count + invoice_count = Invoice.count + invoice_items_count = InvoiceItem.count + subscriptions_count = Subscription.count + users_credit_count = UsersCredit.count + + VCR.use_cassette('reservations_machine_and_plan_using_coupon_success') do + post '/api/payments/confirm_payment', + params: { + payment_method_id: stripe_payment_method, + cart_items: { + reservation: { + reservable_id: machine.id, + reservable_type: machine.class.name, + slots_attributes: [ + { + start_at: availability.start_at.to_s(:iso8601), + end_at: (availability.start_at + 1.hour).to_s(:iso8601), + availability_id: availability.id + } + ], + plan_id: plan.id + }, + coupon_code: 'SUNNYFABLAB' + } + }.to_json, headers: default_headers end + # general assertions + assert_equal 201, response.status + assert_equal reservations_count + 1, Reservation.count + assert_equal invoice_count + 1, Invoice.count + assert_equal invoice_items_count + 2, InvoiceItem.count + assert_equal users_credit_count, UsersCredit.count + assert_equal subscriptions_count + 1, Subscription.count - test 'user reserves a training and a subscription with payment schedule' do - login_as(@user_without_subscription, scope: :user) + # subscription assertions + assert_equal 1, @user_without_subscription.subscriptions.count + assert_not_nil @user_without_subscription.subscribed_plan + assert_equal plan.id, @user_without_subscription.subscribed_plan.id - reservations_count = Reservation.count - invoice_count = Invoice.count - invoice_items_count = InvoiceItem.count - subscriptions_count = Subscription.count - users_credit_count = UsersCredit.count - payment_schedule_count = PaymentSchedule.count - payment_schedule_items_count = PaymentScheduleItem.count + # reservation assertions + reservation = Reservation.last - training = Training.find(1) - availability = training.availabilities.first - plan = Plan.find_by(group_id: @user_without_subscription.group.id, type: 'Plan', base_name: 'Abonnement mensualisable') + assert reservation.invoice + assert_equal 2, reservation.invoice.invoice_items.count - VCR.use_cassette('reservations_training_subscription_with_payment_schedule') do - get "/api/payments/setup_intent/#{@user_without_subscription.id}" + # invoice assertions + invoice = reservation.invoice - # Check response format & status - assert_equal 200, response.status, response.body - assert_equal Mime[:json], response.content_type + refute invoice.stp_payment_intent_id.blank? + refute invoice.total.blank? + assert invoice.check_footprint - # Check the response - setup_intent = json_response(response.body) - assert_not_nil setup_intent[:client_secret] - assert_not_nil setup_intent[:id] - assert_match /^#{setup_intent[:id]}_secret_/, setup_intent[:client_secret] + # invoice_items assertions + ## reservation + reservation_item = invoice.invoice_items.where(subscription_id: nil).first - # Confirm the intent - stripe_res = Stripe::SetupIntent.confirm( - setup_intent[:id], - { payment_method: stripe_payment_method }, - { api_key: Setting.get('stripe_secret_key') } - ) + assert_not_nil reservation_item + assert_equal reservation_item.amount, machine.prices.find_by(group_id: @user_without_subscription.group_id, plan_id: plan.id).amount + assert reservation_item.check_footprint + ## subscription + subscription_item = invoice.invoice_items.where.not(subscription_id: nil).first - # check the confirmation - assert_equal setup_intent[:id], stripe_res.id - assert_equal 'succeeded', stripe_res.status - assert_equal 'off_session', stripe_res.usage + assert_not_nil subscription_item + + subscription = Subscription.find(subscription_item.subscription_id) + + assert_equal subscription_item.amount, plan.amount + assert_equal subscription.plan_id, plan.id + assert subscription_item.check_footprint + + # invoice assertions + invoice = Invoice.find_by(invoiced: reservation) + assert_invoice_pdf invoice + + VCR.use_cassette('reservations_machine_and_plan_using_coupon_retrieve_invoice_from_stripe') do + stp_intent = Stripe::PaymentIntent.retrieve(invoice.stp_payment_intent_id, api_key: Setting.get('stripe_secret_key')) + assert_equal stp_intent.amount, invoice.total + end + + # notifications + assert_not_empty Notification.where(attached_object: reservation) + assert_not_empty Notification.where(attached_object: subscription) + end + + test 'user reserves a training with an expired coupon with error' do + login_as(@user_without_subscription, scope: :user) + + training = Training.find(1) + availability = training.availabilities.first + + reservations_count = Reservation.count + invoice_count = Invoice.count + invoice_items_count = InvoiceItem.count + notifications_count = Notification.count + + VCR.use_cassette('reservations_training_with_expired_coupon_error') do + post '/api/payments/confirm_payment', + params: { + payment_method_id: stripe_payment_method, + cart_items: { + reservation: { + user_id: @user_without_subscription.id, + reservable_id: training.id, + reservable_type: training.class.name, + card_token: stripe_payment_method, + slots_attributes: [ + { + start_at: availability.start_at.to_s(:iso8601), + end_at: (availability.start_at + 1.hour).to_s(:iso8601), + availability_id: availability.id + } + ] + }, + coupon_code: 'XMAS10' + } + }.to_json, headers: default_headers + end + + # general assertions + assert_equal 422, response.status + assert_equal reservations_count, Reservation.count + assert_equal invoice_count, Invoice.count + assert_equal invoice_items_count, InvoiceItem.count + assert_equal notifications_count, Notification.count + + # subscription assertions + assert_equal 0, @user_without_subscription.subscriptions.count + assert_nil @user_without_subscription.subscribed_plan + end - post '/api/payments/confirm_payment_schedule', - params: { - setup_intent_id: setup_intent[:id], - cart_items: { - reservation: { - reservable_id: training.id, - reservable_type: training.class.name, - slots_attributes: [ - { - start_at: availability.start_at.to_s(:iso8601), - end_at: (availability.start_at + 1.hour).to_s(:iso8601), - availability_id: availability.id - } - ], - plan_id: plan.id, - payment_schedule: true - } - } - }.to_json, headers: default_headers - end + test 'user reserves a training and a subscription with payment schedule' do + login_as(@user_without_subscription, scope: :user) + + reservations_count = Reservation.count + invoice_count = Invoice.count + invoice_items_count = InvoiceItem.count + subscriptions_count = Subscription.count + users_credit_count = UsersCredit.count + payment_schedule_count = PaymentSchedule.count + payment_schedule_items_count = PaymentScheduleItem.count + + training = Training.find(1) + availability = training.availabilities.first + plan = Plan.find_by(group_id: @user_without_subscription.group.id, type: 'Plan', base_name: 'Abonnement mensualisable') + + VCR.use_cassette('reservations_training_subscription_with_payment_schedule') do + get "/api/payments/setup_intent/#{@user_without_subscription.id}" # Check response format & status - assert_equal 201, response.status, response.body + assert_equal 200, response.status, response.body assert_equal Mime[:json], response.content_type - assert_equal reservations_count + 1, Reservation.count, 'missing the reservation' - assert_equal invoice_count, Invoice.count, "an invoice was generated but it shouldn't" - assert_equal invoice_items_count, InvoiceItem.count, "some invoice items were generated but they shouldn't" - assert_equal users_credit_count, UsersCredit.count, "user's credits count has changed but it shouldn't" - assert_equal subscriptions_count + 1, Subscription.count, 'missing the subscription' - assert_equal payment_schedule_count + 1, PaymentSchedule.count, 'missing the payment schedule' - assert_equal payment_schedule_items_count + 12, PaymentScheduleItem.count, 'missing some payment schedule items' - # subscription assertions - assert_equal 1, @user_without_subscription.subscriptions.count - assert_not_nil @user_without_subscription.subscribed_plan, "user's subscribed plan was not found" - assert_not_nil @user_without_subscription.subscription, "user's subscription was not found" - assert_equal plan.id, @user_without_subscription.subscribed_plan.id, "user's plan does not match" + # Check the response + setup_intent = json_response(response.body) + assert_not_nil setup_intent[:client_secret] + assert_not_nil setup_intent[:id] + assert_match /^#{setup_intent[:id]}_secret_/, setup_intent[:client_secret] - # Check the answer - reservation = json_response(response.body) - assert_equal plan.id, reservation[:user][:subscribed_plan][:id], 'subscribed plan does not match' + # Confirm the intent + stripe_res = Stripe::SetupIntent.confirm( + setup_intent[:id], + { payment_method: stripe_payment_method }, + { api_key: Setting.get('stripe_secret_key') } + ) + + # check the confirmation + assert_equal setup_intent[:id], stripe_res.id + assert_equal 'succeeded', stripe_res.status + assert_equal 'off_session', stripe_res.usage + + + post '/api/payments/confirm_payment_schedule', + params: { + setup_intent_id: setup_intent[:id], + cart_items: { + reservation: { + reservable_id: training.id, + reservable_type: training.class.name, + slots_attributes: [ + { + start_at: availability.start_at.to_s(:iso8601), + end_at: (availability.start_at + 1.hour).to_s(:iso8601), + availability_id: availability.id + } + ], + plan_id: plan.id, + payment_schedule: true + } + } + }.to_json, headers: default_headers end + + # Check response format & status + assert_equal 201, response.status, response.body + assert_equal Mime[:json], response.content_type + assert_equal reservations_count + 1, Reservation.count, 'missing the reservation' + assert_equal invoice_count, Invoice.count, "an invoice was generated but it shouldn't" + assert_equal invoice_items_count, InvoiceItem.count, "some invoice items were generated but they shouldn't" + assert_equal users_credit_count, UsersCredit.count, "user's credits count has changed but it shouldn't" + assert_equal subscriptions_count + 1, Subscription.count, 'missing the subscription' + assert_equal payment_schedule_count + 1, PaymentSchedule.count, 'missing the payment schedule' + assert_equal payment_schedule_items_count + 12, PaymentScheduleItem.count, 'missing some payment schedule items' + + # subscription assertions + assert_equal 1, @user_without_subscription.subscriptions.count + assert_not_nil @user_without_subscription.subscribed_plan, "user's subscribed plan was not found" + assert_not_nil @user_without_subscription.subscription, "user's subscription was not found" + assert_equal plan.id, @user_without_subscription.subscribed_plan.id, "user's plan does not match" + + # Check the answer + reservation = json_response(response.body) + assert_equal plan.id, reservation[:user][:subscribed_plan][:id], 'subscribed plan does not match' end end From 40c78974b81896ca1570dd01fa381c5b98604929 Mon Sep 17 00:00:00 2001 From: Sylvain Date: Wed, 16 Dec 2020 18:33:43 +0100 Subject: [PATCH 6/9] refactoring of reservation:pay_and_save TODO: debug with tests, refactor subscription:pay_and_save on the same template --- app/controllers/api/payments_controller.rb | 3 +- .../api/reservations_controller.rb | 3 +- app/models/payment_schedule.rb | 11 ++ app/models/payment_schedule_item.rb | 19 ++ app/models/reservation.rb | 187 ++---------------- app/models/subscription.rb | 16 ++ app/services/invoices_service.rb | 133 +++++++++++++ app/services/payment_schedule_service.rb | 4 +- app/services/reservations/reserve.rb | 83 +++++++- ...5_add_stp_payment_intent_id_to_invoices.rb | 2 + ...027101809_create_payment_schedule_items.rb | 1 + db/structure.sql | 1 + .../subscriptions/create_as_admin_test.rb | 2 + 13 files changed, 287 insertions(+), 178 deletions(-) diff --git a/app/controllers/api/payments_controller.rb b/app/controllers/api/payments_controller.rb index ba5b9b3e5..2bc906e1f 100644 --- a/app/controllers/api/payments_controller.rb +++ b/app/controllers/api/payments_controller.rb @@ -106,7 +106,8 @@ class API::PaymentsController < API::ApiController .pay_and_save(@reservation, payment_details: details, payment_intent_id: intent.id, - schedule: params[:cart_items][:reservation][:payment_schedule]) + schedule: params[:cart_items][:reservation][:payment_schedule], + payment_method: params[:cart_items][:reservation][:payment_method]) if intent.class == Stripe::PaymentIntent Stripe::PaymentIntent.update( intent.id, diff --git a/app/controllers/api/reservations_controller.rb b/app/controllers/api/reservations_controller.rb index 50f0ee6f4..fd52e4d9e 100644 --- a/app/controllers/api/reservations_controller.rb +++ b/app/controllers/api/reservations_controller.rb @@ -37,7 +37,8 @@ class API::ReservationsController < API::ApiController is_reserve = Reservations::Reserve.new(user_id, current_user.invoicing_profile.id) .pay_and_save(@reservation, payment_details: price[:price_details], - schedule: params[:reservation][:payment_schedule]) + schedule: params[:reservation][:payment_schedule], + payment_method: params[:reservation][:payment_method]) if is_reserve SubscriptionExtensionAfterReservation.new(@reservation).extend_subscription_if_eligible diff --git a/app/models/payment_schedule.rb b/app/models/payment_schedule.rb index 0da41b0e0..ad8413f47 100644 --- a/app/models/payment_schedule.rb +++ b/app/models/payment_schedule.rb @@ -33,6 +33,13 @@ class PaymentSchedule < ApplicationRecord save end + def set_wallet_transaction(amount, transaction_id) + raise InvalidFootprintError unless check_footprint + + update_columns(wallet_amount: amount, wallet_transaction_id: transaction_id) + chain_record + end + def chain_record self.footprint = compute_footprint save! @@ -46,4 +53,8 @@ class PaymentSchedule < ApplicationRecord def compute_footprint FootprintService.compute_footprint(PaymentSchedule, self) end + + def check_footprint + payment_schedule_items.map(&:check_footprint).all? && footprint == compute_footprint + end end diff --git a/app/models/payment_schedule_item.rb b/app/models/payment_schedule_item.rb index 54733021d..46e0f1741 100644 --- a/app/models/payment_schedule_item.rb +++ b/app/models/payment_schedule_item.rb @@ -4,4 +4,23 @@ class PaymentScheduleItem < ApplicationRecord belongs_to :payment_schedule belongs_to :invoice + after_create :chain_record + + def chain_record + self.footprint = compute_footprint + save! + FootprintDebug.create!( + footprint: footprint, + data: FootprintService.footprint_data(PaymentScheduleItem, self), + klass: PaymentScheduleItem.name + ) + end + + def check_footprint + footprint == compute_footprint + end + + def compute_footprint + FootprintService.compute_footprint(PaymentScheduleItem, self) + end end diff --git a/app/models/reservation.rb b/app/models/reservation.rb index 9726a3766..f7794f181 100644 --- a/app/models/reservation.rb +++ b/app/models/reservation.rb @@ -30,133 +30,33 @@ class Reservation < ApplicationRecord after_commit :notify_member_create_reservation, on: :create after_commit :notify_admin_member_create_reservation, on: :create after_save :update_event_nb_free_places, if: proc { |reservation| reservation.reservable_type == 'Event' } - after_create :debit_user_wallet ## - # Generate an array of {Stripe::InvoiceItem} with the elements in the current reservation, price included. - # @param payment_details {Hash} as generated by Price.compute + # These checks will run before the invoice/payment-schedule is generated ## - def generate_invoice_items(payment_details = nil) + def pre_check # check that none of the reserved availabilities was locked slots.each do |slot| raise LockedError if slot.availability.lock end - - case reservable - # === Event reservation === - when Event - slots.each do |slot| - description = "#{reservable.name}\n" - description += if slot.start_at.to_date != slot.end_at.to_date - I18n.t('events.from_STARTDATE_to_ENDDATE', - STARTDATE: I18n.l(slot.start_at.to_date, format: :long), - ENDDATE: I18n.l(slot.end_at.to_date, format: :long)) + ' ' + - I18n.t('events.from_STARTTIME_to_ENDTIME', - STARTTIME: I18n.l(slot.start_at, format: :hour_minute), - ENDTIME: I18n.l(slot.end_at, format: :hour_minute)) - else - "#{I18n.l slot.start_at.to_date, format: :long} #{I18n.l slot.start_at, format: :hour_minute}" \ - " - #{I18n.l slot.end_at, format: :hour_minute}" - end - - price_slot = payment_details[:elements][:slots].detect { |p_slot| p_slot[:start_at].to_time.in_time_zone == slot[:start_at] } - invoice.invoice_items.push InvoiceItem.new( - amount: price_slot[:price], - description: description - ) - end - # === Space|Machine|Training reservation === - else - slots.each do |slot| - description = reservable.name + - " #{I18n.l slot.start_at, format: :long} - #{I18n.l slot.end_at, format: :hour_minute}" - - price_slot = payment_details[:elements][:slots].detect { |p_slot| p_slot[:start_at].to_time.in_time_zone == slot[:start_at] } - invoice.invoice_items.push InvoiceItem.new( - amount: price_slot[:price], - description: description - ) - end - end - - # === Coupon === - @coupon = payment_details[:coupon] - - # === Wallet === - @wallet_amount_debit = wallet_amount_debit end - # check reservation amount total and strip invoice total to pay is equal - # @param stp_invoice[Stripe::Invoice] - # @param coupon_code[String] - # return Boolean - def is_equal_reservation_total_and_stp_invoice_total(stp_invoice, coupon_code = nil) - compute_amount_total_to_pay(coupon_code) == stp_invoice.total + ## Generate the subscription associated with for the current reservation + def generate_subscription + return unless plan_id + + self.subscription = Subscription.find_or_initialize_by(statistic_profile_id: statistic_profile_id) + subscription.attributes = { plan_id: plan_id, statistic_profile_id: statistic_profile_id, expiration_date: nil } + + subscription.init_save + subscription end - def clear_payment_info(card, invoice) - card&.delete - if invoice - invoice.closed = true - invoice.save - end - rescue Stripe::InvalidRequestError => e - logger.error e - rescue Stripe::AuthenticationError => e - logger.error e - rescue Stripe::APIConnectionError => e - logger.error e - rescue Stripe::StripeError => e - logger.error e - rescue StandardError => e - logger.error e - end - - def clean_pending_strip_invoice_items - pending_invoice_items = Stripe::InvoiceItem.list( - { customer: user.stp_customer_id, limit: 100 }, - { api_key: Setting.get('stripe_secret_key') } - ).data.select { |ii| ii.invoice.nil? } - pending_invoice_items.each(&:delete) - end - - def save_with_payment(operator_profile_id, payment_details, payment_intent_id = nil, schedule: false) - operator = InvoicingProfile.find(operator_profile_id)&.user - method = operator&.admin? || (operator&.manager? && operator != user) ? nil : 'stripe' - - build_invoice( - invoicing_profile: user.invoicing_profile, - statistic_profile: user.statistic_profile, - operator_profile_id: operator_profile_id, - stp_payment_intent_id: payment_intent_id, - payment_method: method - ) - generate_invoice_items(payment_details) - - return false unless valid? - - if plan_id - self.subscription = Subscription.find_or_initialize_by(statistic_profile_id: statistic_profile_id) - subscription.attributes = { plan_id: plan_id, statistic_profile_id: statistic_profile_id, expiration_date: nil } - if subscription.save_with_payment(operator_profile_id, invoice: false, schedule: schedule) - invoice.invoice_items.push InvoiceItem.new( - amount: payment_details[:elements][:plan], - description: subscription.plan.name, - subscription_id: subscription.id - ) - set_total_and_coupon(payment_details[:coupon]) - save! - else - errors[:card] << subscription.errors[:card].join - return false - end - else - set_total_and_coupon(payment_details[:coupon]) - save! - end - + ## + # These actions will be realized after the reservation is initially saved (on creation) + ## + def post_save UsersCredits::Manager.new(reservation: self).update_credits - true end # @param canceled if true, count the number of seats for this reservation, including canceled seats @@ -219,61 +119,4 @@ class Reservation < ApplicationRecord receiver: User.admins_and_managers, attached_object: self end - - def cart_total - total = (invoice.invoice_items.map(&:amount).map(&:to_i).reduce(:+) or 0) - if plan_id.present? - plan = Plan.find(plan_id) - total += plan.amount - end - total - end - - def wallet_amount_debit - total = cart_total - total = CouponService.new.apply(total, @coupon, user.id) if @coupon - - wallet_amount = (user.wallet.amount * 100).to_i - - wallet_amount >= total ? total : wallet_amount - end - - def debit_user_wallet - return unless @wallet_amount_debit.present? && @wallet_amount_debit != 0 - - amount = @wallet_amount_debit / 100.0 - wallet_transaction = WalletService.new(user: user, wallet: user.wallet).debit(amount, self) - # wallet debit success - raise DebitWalletError unless wallet_transaction - - invoice.set_wallet_transaction(@wallet_amount_debit, wallet_transaction.id) - end - - # this function only use for compute total of reservation before save - def compute_amount_total_to_pay(coupon_code = nil) - total = invoice.invoice_items.map(&:amount).map(&:to_i).reduce(:+) - unless coupon_code.nil? - cp = Coupon.find_by(code: coupon_code) - raise InvalidCouponError unless !cp.nil? && cp.status(user.id) == 'active' - - total = CouponService.new.apply(total, cp, user.id) - end - total - wallet_amount_debit - end - - ## - # Set the total price to the reservation's invoice, summing its whole items. - # Additionally a coupon may be applied to this invoice to make a discount on the total price - # @param [coupon] {Coupon} optional coupon to apply to the invoice - ## - def set_total_and_coupon(coupon = nil) - total = invoice.invoice_items.map(&:amount).map(&:to_i).reduce(:+) - - unless coupon.nil? - total = CouponService.new.apply(total, coupon, user.id) - invoice.coupon_id = coupon.id - end - - invoice.total = total - end end diff --git a/app/models/subscription.rb b/app/models/subscription.rb index 4689a1f4d..540396345 100644 --- a/app/models/subscription.rb +++ b/app/models/subscription.rb @@ -19,6 +19,22 @@ class Subscription < ApplicationRecord after_save :notify_admin_subscribed_plan after_save :notify_partner_subscribed_plan, if: :of_partner_plan? + ## + # Set the inner properties of the subscription, init the user's credits and save the subscription + # into the DB + # @return {boolean} true, if the operation succeeded + ## + def init_save + return false unless valid? + + set_expiration_date + return false unless save + + UsersCredits::Manager.new(user: user).reset_credits + true + end + + # TODO, remove this method, refactor like services/Reservations::Reserve # @param invoice if true then only the subscription is payed, without reservation # if false then the subscription is payed with reservation # @param payment_method is only used for schedules diff --git a/app/services/invoices_service.rb b/app/services/invoices_service.rb index 3a0f45e0c..52f28a411 100644 --- a/app/services/invoices_service.rb +++ b/app/services/invoices_service.rb @@ -59,4 +59,137 @@ class InvoicesService end { direction: direction, order_key: order_key } end + + ## + # Create a Stripe::Invoice with an associated array of Stripe::InvoiceItem matching the given parameters + # @param payment_details {Hash} as generated by Price.compute + # @param operator_profile_id {Number} ID of the user that operates the invoice generation (may be an admin, a manager or the customer himself) + # @param reservation {Reservation} the booking reservation, if any + # @param subscription {Subscription} the booking subscription, if any + # @param payment_intent_id {String} ID of the Stripe::PaymentIntend, if the current invoice is paid by stripe + ## + def self.create(payment_details, operator_profile_id, reservation: nil, subscription: nil, payment_intent_id: nil) + user = reservation&.user || subscription&.user + operator = InvoicingProfile.find(operator_profile_id)&.user + method = operator&.admin? || (operator&.manager? && operator != user) ? nil : 'stripe' + + invoice = Invoice.new( + invoiced: subscription || reservation, + invoicing_profile: user.invoicing_profile, + statistic_profile: user.statistic_profile, + operator_profile_id: operator_profile_id, + stp_payment_intent_id: payment_intent_id, + payment_method: method + ) + + InvoicesService.generate_invoice_items(invoice, payment_details, reservation: reservation, subscription: subscription) + InvoicesService.set_total_and_coupon(invoice, user, payment_details[:coupon]) + invoice + end + + ## + # Generate an array of {Stripe::InvoiceItem} with the elements in provided reservation, price included. + # @param invoice {Invoice} the parent invoice + # @param payment_details {Hash} as generated by Price.compute + ## + def self.generate_invoice_items(invoice, payment_details, reservation: nil, subscription: nil) + if reservation + case reservation.reservable + # === Event reservation === + when Event + InvoicesService.generate_event_item(invoice, reservation, payment_details) + # === Space|Machine|Training reservation === + else + InvoicesService.generate_generic_item(invoice, reservation, payment_details) + end + end + + return unless subscription || reservation&.plan_id + + subscription = reservation.generate_subscription if !subscription && reservation.plan_id + InvoicesService.generate_subscription_item(invoice, subscription, payment_details) + end + + ## + # Generate Stripe::InvoiceItem for each slot in the given reservation and save them in invoice.invoice_items. + # This method must be called if reservation.reservable is an Event + ## + def self.generate_event_item(invoice, reservation, payment_details) + raise TypeError unless reservation.reservable.class == Event + + reservation.slots.each do |slot| + description = "#{reservation.reservable.name}\n" + description += if slot.start_at.to_date != slot.end_at.to_date + I18n.t('events.from_STARTDATE_to_ENDDATE', + STARTDATE: I18n.l(slot.start_at.to_date, format: :long), + ENDDATE: I18n.l(slot.end_at.to_date, format: :long)) + ' ' + + I18n.t('events.from_STARTTIME_to_ENDTIME', + STARTTIME: I18n.l(slot.start_at, format: :hour_minute), + ENDTIME: I18n.l(slot.end_at, format: :hour_minute)) + else + "#{I18n.l slot.start_at.to_date, format: :long} #{I18n.l slot.start_at, format: :hour_minute}" \ + " - #{I18n.l slot.end_at, format: :hour_minute}" + end + + price_slot = payment_details[:elements][:slots].detect { |p_slot| p_slot[:start_at].to_time.in_time_zone == slot[:start_at] } + invoice.invoice_items.push InvoiceItem.new( + amount: price_slot[:price], + description: description + ) + end + end + + ## + # Generate Stripe::InvoiceItem for each slot in the given reservation and save them in invoice.invoice_items. + # This method must be called if reservation.reservable is a Space, a Machine or a Training + ## + def self.generate_generic_item(invoice, reservation, payment_details) + raise TypeError unless [Space, Machine, Training].include? reservation.reservable.class + + reservation.slots.each do |slot| + description = reservation.reservable.name + + " #{I18n.l slot.start_at, format: :long} - #{I18n.l slot.end_at, format: :hour_minute}" + + price_slot = payment_details[:elements][:slots].detect { |p_slot| p_slot[:start_at].to_time.in_time_zone == slot[:start_at] } + invoice.invoice_items.push InvoiceItem.new( + amount: price_slot[:price], + description: description + ) + end + end + + ## + # Generate a Stripe::InvoiceItem for the given subscription and save it in invoice.invoice_items. + # This method must be called only with a valid subscription + ## + def self.generate_subscription_item(invoice, subscription, payment_details) + raise TypeError unless subscription + + invoice.invoice_items.push InvoiceItem.new( + amount: payment_details[:elements][:plan], + description: subscription.plan.name, + subscription_id: subscription.id + ) + end + + + ## + # Set the total price to the reservation's invoice, summing its whole items. + # Additionally a coupon may be applied to this invoice to make a discount on the total price + # @param invoice {Invoice} the invoice to fill + # @param user {User} the customer + # @param [coupon] {Coupon} optional coupon to apply to the invoice + ## + def self.set_total_and_coupon(invoice, user, coupon = nil) + return unless invoice + + total = invoice.invoice_items.map(&:amount).map(&:to_i).reduce(:+) + + unless coupon.nil? + total = CouponService.new.apply(total, coupon, user.id) + invoice.coupon_id = coupon.id + end + + invoice.total = total + end end diff --git a/app/services/payment_schedule_service.rb b/app/services/payment_schedule_service.rb index 137306e12..aa299e114 100644 --- a/app/services/payment_schedule_service.rb +++ b/app/services/payment_schedule_service.rb @@ -47,11 +47,11 @@ class PaymentScheduleService end def create(subscription, total, coupon: nil, operator: nil, payment_method: nil, reservation: nil, user: nil) - schedule = compute(subscription.plan, total, coupon) + schedule = compute(reservation ? reservation.subscription.plan : subscription.plan, total, coupon) ps = schedule[:payment_schedule] items = schedule[:items] - ps.scheduled = subscription + ps.scheduled = reservation || subscription ps.payment_method = payment_method ps.operator_profile = operator.invoicing_profile ps.invoicing_profile = user.invoicing_profile diff --git a/app/services/reservations/reserve.rb b/app/services/reservations/reserve.rb index 44f3a9fe3..6b942f14b 100644 --- a/app/services/reservations/reserve.rb +++ b/app/services/reservations/reserve.rb @@ -9,8 +9,87 @@ class Reservations::Reserve @operator_profile_id = operator_profile_id end - def pay_and_save(reservation, payment_details: nil, payment_intent_id: nil, schedule: false) + ## + # Confirm the payment of the given reservation, generate the associated documents and save teh record into + # the database. + ## + def pay_and_save(reservation, payment_details: nil, payment_intent_id: nil, schedule: false, payment_method: nil) + user = User.find(user_id) reservation.statistic_profile_id = StatisticProfile.find_by(user_id: user_id).id - reservation.save_with_payment(operator_profile_id, payment_details, payment_intent_id, schedule: schedule) + + reservation.pre_check + payment = if schedule + generate_schedule(reservation: reservation, + total: payment_details[:before_coupon], + operator_profile_id: operator_profile_id, + user: user, + payment_method: payment_method, + coupon_code: payment_details[:coupon]) + else + generate_invoice(reservation, operator_profile_id, payment_details, payment_intent_id) + end + payment.save + debit_user_wallet(payment, user, reservation) + reservation.post_save + end + + ## + # Generate the invoice for the given reservation+subscription + ## + def generate_schedule(reservation: nil, total: nil, operator_profile_id: nil, user: nil, payment_method: nil, coupon_code: nil) + operator = InvoicingProfile.find(operator_profile_id)&.user + coupon = Coupon.find_by(code: coupon_code) unless coupon_code.nil? + + PaymentScheduleService.new.create( + nil, + total, + coupon: coupon, + operator: operator, + payment_method: payment_method, + user: user, + reservation: reservation + ) + end + + ## + # Generate the invoice for the given reservation + ## + def generate_invoice(reservation, operator_profile_id, payment_details, payment_intent_id = nil) + InvoicesService.create( + payment_details, + operator_profile_id, + reservation: reservation, + payment_intent_id: payment_intent_id + ) + end + + ## + # Compute the amount decreased from the user's wallet, if applicable + # @param payment {Invoice|PaymentSchedule} + # @param user {User} the customer + # @param coupon {Coupon|String} Coupon object or code + ## + def wallet_amount_debit(payment, user, coupon = nil) + total = payment.total + total = CouponService.new.apply(total, coupon, user.id) if coupon + + wallet_amount = (user.wallet.amount * 100).to_i + + wallet_amount >= total ? total : wallet_amount + end + + ## + # Subtract the amount of the current reservation from the customer's wallet + ## + def debit_user_wallet(payment, user, reservation) + wallet_amount = wallet_amount_debit(payment, user) + return unless wallet_amount.present? && wallet_amount != 0 + + amount = wallet_amount / 100.0 + wallet_transaction = WalletService.new(user: user, wallet: user.wallet).debit(amount, reservation) + # wallet debit success + raise DebitWalletError unless wallet_transaction + + payment.set_wallet_transaction(wallet_amount, wallet_transaction.id) end end diff --git a/db/migrate/20190910131825_add_stp_payment_intent_id_to_invoices.rb b/db/migrate/20190910131825_add_stp_payment_intent_id_to_invoices.rb index ab7867996..9c420a603 100644 --- a/db/migrate/20190910131825_add_stp_payment_intent_id_to_invoices.rb +++ b/db/migrate/20190910131825_add_stp_payment_intent_id_to_invoices.rb @@ -1,5 +1,7 @@ # frozen_string_literal:true +# From this migration, if the current Invoice is payed with Stripe, it will be stored in database +# using stp_payment_intent_id instead of stp_invoice_id class AddStpPaymentIntentIdToInvoices < ActiveRecord::Migration[4.2] def change add_column :invoices, :stp_payment_intent_id, :string diff --git a/db/migrate/20201027101809_create_payment_schedule_items.rb b/db/migrate/20201027101809_create_payment_schedule_items.rb index 150e22d69..e38fa4fd3 100644 --- a/db/migrate/20201027101809_create_payment_schedule_items.rb +++ b/db/migrate/20201027101809_create_payment_schedule_items.rb @@ -9,6 +9,7 @@ class CreatePaymentScheduleItems < ActiveRecord::Migration[5.2] t.jsonb :details, default: '{}' t.belongs_to :payment_schedule, foreign_key: true t.belongs_to :invoice, foreign_key: true + t.string :footprint t.timestamps end diff --git a/db/structure.sql b/db/structure.sql index e5ce3175b..d82d2e4b8 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -1473,6 +1473,7 @@ CREATE TABLE public.payment_schedule_items ( details jsonb DEFAULT '"{}"'::jsonb, payment_schedule_id bigint, invoice_id bigint, + footprint character varying, created_at timestamp without time zone NOT NULL, updated_at timestamp without time zone NOT NULL ); diff --git a/test/integration/subscriptions/create_as_admin_test.rb b/test/integration/subscriptions/create_as_admin_test.rb index e959403fa..2d996fe8a 100644 --- a/test/integration/subscriptions/create_as_admin_test.rb +++ b/test/integration/subscriptions/create_as_admin_test.rb @@ -60,6 +60,7 @@ class Subscriptions::CreateAsAdminTest < ActionDispatch::IntegrationTest test 'admin takes a subscription with a payment schedule' do user = User.find_by(username: 'jdupond') plan = Plan.find_by(group_id: user.group.id, type: 'Plan', base_name: 'Abonnement mensualisable') + invoice_count = Invoice.count payment_schedule_count = PaymentSchedule.count payment_schedule_items_count = PaymentScheduleItem.count @@ -106,6 +107,7 @@ class Subscriptions::CreateAsAdminTest < ActionDispatch::IntegrationTest # Check generalities assert_equal 201, response.status, response.body assert_equal Mime[:json], response.content_type + assert_equal invoice_count, Invoice.count, "an invoice was generated but it shouldn't" assert_equal payment_schedule_count + 1, PaymentSchedule.count, 'missing the payment schedule' assert_equal payment_schedule_items_count + 12, PaymentScheduleItem.count, 'missing some payment schedule items' From a4131e4d5c1271eeffc62a4828f34d91f3bd96ee Mon Sep 17 00:00:00 2001 From: Sylvain Date: Mon, 21 Dec 2020 12:02:51 +0100 Subject: [PATCH 7/9] fixed new reservation process --- app/services/invoices_service.rb | 10 +++++----- app/services/payment_schedule_service.rb | 4 +++- app/services/reservations/reserve.rb | 1 + 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/app/services/invoices_service.rb b/app/services/invoices_service.rb index 52f28a411..bda6c5fa1 100644 --- a/app/services/invoices_service.rb +++ b/app/services/invoices_service.rb @@ -61,7 +61,7 @@ class InvoicesService end ## - # Create a Stripe::Invoice with an associated array of Stripe::InvoiceItem matching the given parameters + # Create an Invoice with an associated array of InvoiceItem matching the given parameters # @param payment_details {Hash} as generated by Price.compute # @param operator_profile_id {Number} ID of the user that operates the invoice generation (may be an admin, a manager or the customer himself) # @param reservation {Reservation} the booking reservation, if any @@ -88,7 +88,7 @@ class InvoicesService end ## - # Generate an array of {Stripe::InvoiceItem} with the elements in provided reservation, price included. + # Generate an array of {InvoiceItem} with the elements in provided reservation, price included. # @param invoice {Invoice} the parent invoice # @param payment_details {Hash} as generated by Price.compute ## @@ -111,7 +111,7 @@ class InvoicesService end ## - # Generate Stripe::InvoiceItem for each slot in the given reservation and save them in invoice.invoice_items. + # Generate an InvoiceItem for each slot in the given reservation and save them in invoice.invoice_items. # This method must be called if reservation.reservable is an Event ## def self.generate_event_item(invoice, reservation, payment_details) @@ -140,7 +140,7 @@ class InvoicesService end ## - # Generate Stripe::InvoiceItem for each slot in the given reservation and save them in invoice.invoice_items. + # Generate an InvoiceItem for each slot in the given reservation and save them in invoice.invoice_items. # This method must be called if reservation.reservable is a Space, a Machine or a Training ## def self.generate_generic_item(invoice, reservation, payment_details) @@ -159,7 +159,7 @@ class InvoicesService end ## - # Generate a Stripe::InvoiceItem for the given subscription and save it in invoice.invoice_items. + # Generate an InvoiceItem for the given subscription and save it in invoice.invoice_items. # This method must be called only with a valid subscription ## def self.generate_subscription_item(invoice, subscription, payment_details) diff --git a/app/services/payment_schedule_service.rb b/app/services/payment_schedule_service.rb index aa299e114..7f704b101 100644 --- a/app/services/payment_schedule_service.rb +++ b/app/services/payment_schedule_service.rb @@ -47,7 +47,9 @@ class PaymentScheduleService end def create(subscription, total, coupon: nil, operator: nil, payment_method: nil, reservation: nil, user: nil) - schedule = compute(reservation ? reservation.subscription.plan : subscription.plan, total, coupon) + subscription = reservation.generate_subscription if !subscription && reservation.plan_id + + schedule = compute(subscription.plan, total, coupon) ps = schedule[:payment_schedule] items = schedule[:items] diff --git a/app/services/reservations/reserve.rb b/app/services/reservations/reserve.rb index 6b942f14b..77599952b 100644 --- a/app/services/reservations/reserve.rb +++ b/app/services/reservations/reserve.rb @@ -31,6 +31,7 @@ class Reservations::Reserve payment.save debit_user_wallet(payment, user, reservation) reservation.post_save + true end ## From e0ac9d1ac3db37698f9018974fe83eb428fb6e14 Mon Sep 17 00:00:00 2001 From: Sylvain Date: Mon, 21 Dec 2020 14:26:38 +0100 Subject: [PATCH 8/9] Eslint the frontend members controllers --- .../javascript/controllers/admin/members.js | 293 +++++++++--------- 1 file changed, 151 insertions(+), 142 deletions(-) diff --git a/app/frontend/src/javascript/controllers/admin/members.js b/app/frontend/src/javascript/controllers/admin/members.js index 483c6f325..114502eae 100644 --- a/app/frontend/src/javascript/controllers/admin/members.js +++ b/app/frontend/src/javascript/controllers/admin/members.js @@ -37,7 +37,7 @@ */ class MembersController { constructor ($scope, $state, Group, Training) { - // Retrieve the profiles groups (eg. students ...) + // Retrieve the profiles groups (e.g. students ...) Group.query(function (groups) { $scope.groups = groups.filter(function (g) { return (g.slug !== 'admins') && !g.disabled; }); }); // Retrieve the list of available trainings @@ -62,7 +62,7 @@ class MembersController { }; /** - * Shows the birth day datepicker + * Shows the birthday datepicker * @param $event {Object} jQuery event object */ $scope.openDatePicker = function ($event) { @@ -85,7 +85,7 @@ class MembersController { * For use with ngUpload (https://github.com/twilson63/ngUpload). * Intended to be the callback when an upload is done: any raised error will be stacked in the * $scope.alerts array. If everything goes fine, the user is redirected to the members listing page. - * @param content {Object} JSON - The upload's result + * @param content {Object} JSON - The result of the upload */ $scope.submited = function (content) { if ((content.id == null)) { @@ -110,7 +110,7 @@ class MembersController { /** * For use with 'ng-class', returns the CSS class name for the uploads previews. - * The preview may show a placeholder or the content of the file depending on the upload state. + * The preview may show a placeholder, or the content of the file depending on the upload state. * @param v {*} any attribute, will be tested for truthiness (see JS evaluation rules) */ $scope.fileinputClass = function (v) { @@ -143,7 +143,7 @@ Application.Controllers.controller('AdminMembersController', ['$scope', '$sce', searchText: '', // Members ordering/sorting. Default: not sorted order: 'id', - // currently displayed page of members + // the currently displayed page of members page: 1, // true when all members where loaded noMore: false, @@ -158,7 +158,7 @@ Application.Controllers.controller('AdminMembersController', ['$scope', '$sce', }; // admins list - $scope.admins = adminsPromise.admins.filter(function(m) { return m.id != Fablab.superadminId; }); + $scope.admins = adminsPromise.admins.filter(function (m) { return m.id !== Fablab.superadminId; }); // Admins ordering/sorting. Default: not sorted $scope.orderAdmin = null; @@ -210,7 +210,7 @@ Application.Controllers.controller('AdminMembersController', ['$scope', '$sce', * @param orderPartner {string} ordering criterion */ $scope.setOrderPartner = function (orderPartner) { - if ($scope.orderPartner === orderPartner) { + if ($scope.orderPartner === orderPartner) { return $scope.orderPartner = `-${orderPartner}`; } else { return $scope.orderPartner = orderPartner; @@ -229,7 +229,6 @@ Application.Controllers.controller('AdminMembersController', ['$scope', '$sce', } }; - /** * Open a modal dialog allowing the admin to create a new partner user */ @@ -265,12 +264,11 @@ Application.Controllers.controller('AdminMembersController', ['$scope', '$sce', }); }; - /** * Ask for confirmation then delete the specified user * @param memberId {number} identifier of the user to delete */ - $scope.deleteMember = function(memberId) { + $scope.deleteMember = function (memberId) { dialogs.confirm( { resolve: { @@ -289,11 +287,14 @@ Application.Controllers.controller('AdminMembersController', ['$scope', '$sce', $scope.members.splice(findItemIdxById($scope.members, memberId), 1); return growl.success(_t('app.admin.members.member_successfully_deleted')); }, - function (error) { growl.error(_t('app.admin.members.unable_to_delete_the_member')); } + function (error) { + growl.error(_t('app.admin.members.unable_to_delete_the_member')); + console.error(error); + } ); } ); - } + }; /** * Ask for confirmation then delete the specified administrator @@ -319,7 +320,10 @@ Application.Controllers.controller('AdminMembersController', ['$scope', '$sce', admins.splice(findItemIdxById(admins, admin.id), 1); return growl.success(_t('app.admin.members.administrator_successfully_deleted')); }, - function (error) { growl.error(_t('app.admin.members.unable_to_delete_the_administrator')); } + function (error) { + growl.error(_t('app.admin.members.unable_to_delete_the_administrator')); + console.error(error); + } ); } ); @@ -349,11 +353,14 @@ Application.Controllers.controller('AdminMembersController', ['$scope', '$sce', partners.splice(findItemIdxById(partners, partner.id), 1); return growl.success(_t('app.admin.members.partner_successfully_deleted')); }, - function (error) { growl.error(_t('app.admin.members.unable_to_delete_the_partner')); } + function (error) { + growl.error(_t('app.admin.members.unable_to_delete_the_partner')); + console.error(error); + } ); } ); - } + }; /** * Ask for confirmation then delete the specified manager @@ -379,11 +386,14 @@ Application.Controllers.controller('AdminMembersController', ['$scope', '$sce', managers.splice(findItemIdxById(managers, manager.id), 1); return growl.success(_t('app.admin.members.manager_successfully_deleted')); }, - function (error) { growl.error(_t('app.admin.members.unable_to_delete_the_manager')); } + function (error) { + growl.error(_t('app.admin.members.unable_to_delete_the_manager')); + console.error(error); + } ); } ); - } + }; /** * Callback for the 'load more' button. @@ -399,7 +409,7 @@ Application.Controllers.controller('AdminMembersController', ['$scope', '$sce', */ $scope.updateTextSearch = function () { if (searchTimeout) clearTimeout(searchTimeout); - searchTimeout = setTimeout(function() { + searchTimeout = setTimeout(function () { resetSearchMember(); memberSearch(); }, 300); @@ -425,9 +435,8 @@ Application.Controllers.controller('AdminMembersController', ['$scope', '$sce', }); }; - /** - * Setup the feature-tour for the admin/members page. + * Set up the feature-tour for the admin/members page. * This is intended as a contextual help (when pressing F1) */ $scope.setupMembersTour = function () { @@ -570,7 +579,7 @@ Application.Controllers.controller('AdminMembersController', ['$scope', '$sce', if (settingsPromise.feature_tour_display !== 'manual' && $scope.currentUser.profile.tours.indexOf('members') < 0) { uitour.start(); } - } + }; /* PRIVATE SCOPE */ @@ -586,22 +595,22 @@ Application.Controllers.controller('AdminMembersController', ['$scope', '$sce', /** * Will temporize the search query to prevent overloading the API */ - var searchTimeout = null; + let searchTimeout = null; /** * Iterate through the provided array and return the index of the requested item - * @param items {Array} full list of users with role 'admin' + * @param items {Array} full list of users with the 'admin' role * @param id {Number} id of the item to retrieve in the list * @returns {Number} index of the requested item, in the provided array */ - var findItemIdxById = function (items, id) { + const findItemIdxById = function (items, id) { return (items.map(function (item) { return item.id; })).indexOf(id); }; /** - * Reinitialize the context of members's search to display new results set + * Reinitialize the context of the search to display new results set */ - var resetSearchMember = function () { + const resetSearchMember = function () { $scope.member.noMore = false; $scope.member.page = 1; }; @@ -609,9 +618,9 @@ Application.Controllers.controller('AdminMembersController', ['$scope', '$sce', /** * Run a search query with the current parameters set ($scope.member[searchText,order,page]) * and affect or append the result in $scope.members, depending on the concat parameter - * @param [concat] {boolean} if true, the result will be append to $scope.members instead of being affected + * @param [concat] {boolean} if true, the result will be appended to $scope.members instead of being replaced */ - var memberSearch = function (concat) { + const memberSearch = function (concat) { Member.list({ query: { search: $scope.member.searchText, @@ -666,7 +675,6 @@ Application.Controllers.controller('EditMemberController', ['$scope', '$state', // the user subscription if (($scope.user.subscribed_plan != null) && ($scope.user.subscription != null)) { $scope.subscription = $scope.user.subscription; - $scope.subscription.expired_at = $scope.subscription.expired_at; } else { Plan.query({ group_id: $scope.user.group_id }, function (plans) { $scope.plans = plans; @@ -696,16 +704,15 @@ Application.Controllers.controller('EditMemberController', ['$scope', '$state', /** * Open a modal dialog asking for confirmation to change the role of the given user - * @param userId {number} id of the user to "promote" * @returns {*} */ - $scope.changeUserRole = function() { + $scope.changeUserRole = function () { const modalInstance = $uibModal.open({ animation: true, templateUrl: '/admin/members/change_role_modal.html', size: 'lg', resolve: { - user() { return $scope.user; } + user () { return $scope.user; } }, controller: ['$scope', '$uibModalInstance', 'Member', 'user', '_t', function ($scope, $uibModalInstance, Member, user, _t) { $scope.user = user; @@ -715,7 +722,7 @@ Application.Controllers.controller('EditMemberController', ['$scope', '$state', $scope.roles = [ { key: 'admin', label: _t('app.admin.members_edit.admin') }, { key: 'manager', label: _t('app.admin.members_edit.manager'), notAnOption: (user.role === 'admin') }, - { key: 'member', label: _t('app.admin.members_edit.member'), notAnOption: (user.role === 'admin' || user.role === 'manager') }, + { key: 'member', label: _t('app.admin.members_edit.member'), notAnOption: (user.role === 'admin' || user.role === 'manager') } ]; $scope.ok = function () { @@ -740,7 +747,7 @@ Application.Controllers.controller('EditMemberController', ['$scope', '$state', return modalInstance.result.then(function (user) { // remove the user for the old list add to the new }); - } + }; /** * Open a modal dialog, allowing the admin to extend the current user's subscription (freely or not) @@ -754,6 +761,7 @@ Application.Controllers.controller('EditMemberController', ['$scope', '$state', size: 'lg', controller: ['$scope', '$uibModalInstance', 'Subscription', function ($scope, $uibModalInstance, Subscription) { $scope.new_expired_at = angular.copy(subscription.expired_at); + $scope.scheduled = subscription.scheduled; $scope.free = free; $scope.datePicker = { opened: false, @@ -778,7 +786,10 @@ Application.Controllers.controller('EditMemberController', ['$scope', '$state', growl.success(_t('app.admin.members_edit.you_successfully_changed_the_expiration_date_of_the_user_s_subscription')); return $uibModalInstance.close(_subscription); }, - function (error) { growl.error(_t('app.admin.members_edit.a_problem_occurred_while_saving_the_date')); } + function (error) { + growl.error(_t('app.admin.members_edit.a_problem_occurred_while_saving_the_date')); + console.error(error); + } ); }; @@ -792,14 +803,14 @@ Application.Controllers.controller('EditMemberController', ['$scope', '$state', /** * Open a modal dialog allowing the admin to set a subscription for the given user. * @param user {Object} User object, user currently reviewed, as recovered from GET /api/members/:id - * @param plans {Array} List of plans, availables for the currently reviewed user, as recovered from GET /api/plans + * @param plans {Array} List of plans, available for the currently reviewed user, as recovered from GET /api/plans */ $scope.createSubscriptionModal = function (user, plans) { const modalInstance = $uibModal.open({ animation: true, templateUrl: '/admin/subscriptions/create_modal.html', size: 'lg', - controller: ['$scope', '$uibModalInstance', 'Subscription', 'Group', function ($scope, $uibModalInstance, Subscription, Group) { + controller: ['$scope', '$uibModalInstance', 'Subscription', function ($scope, $uibModalInstance, Subscription) { // selected user $scope.user = user; @@ -810,7 +821,7 @@ Application.Controllers.controller('EditMemberController', ['$scope', '$state', * Generate a string identifying the given plan by literal human-readable name * @param plan {Object} Plan object, as recovered from GET /api/plan/:id * @param groups {Array} List of Groups objects, as recovered from GET /api/groups - * @param short {boolean} If true, the generated name will contains the group slug, otherwise the group full name + * @param short {boolean} If true, the generated name will contain the group slug, otherwise the group full name * will be included. * @returns {String} */ @@ -902,8 +913,9 @@ Application.Controllers.controller('EditMemberController', ['$scope', '$state', */ $scope.cancel = function () { $uibModalInstance.dismiss('cancel'); }; } - ] }); - // once the form was validated succesfully ... + ] + }); + // once the form was validated successfully... return modalInstance.result.then(function (wallet) { $scope.wallet = wallet; return Wallet.transactions({ id: wallet.id }, function (transactions) { $scope.transactions = transactions; }); @@ -923,13 +935,12 @@ Application.Controllers.controller('EditMemberController', ['$scope', '$state', const initialize = function () { CSRF.setMetaTags(); - // init the birth date to JS object + // init the birthdate to JS object $scope.user.statistic_profile.birthday = moment($scope.user.statistic_profile.birthday).toDate(); // the user subscription if (($scope.user.subscribed_plan != null) && ($scope.user.subscription != null)) { $scope.subscription = $scope.user.subscription; - $scope.subscription.expired_at = $scope.subscription.expired_at; } else { Plan.query({ group_id: $scope.user.group_id }, function (plans) { $scope.plans = plans; @@ -996,7 +1007,7 @@ Application.Controllers.controller('NewMemberController', ['$scope', '$state', ' * Controller used in the member's import page: import from CSV (admin view) */ Application.Controllers.controller('ImportMembersController', ['$scope', '$state', 'Group', 'Training', 'CSRF', 'tags', 'growl', - function($scope, $state, Group, Training, CSRF, tags, growl) { + function ($scope, $state, Group, Training, CSRF, tags, growl) { CSRF.setMetaTags(); /* PUBLIC SCOPE */ @@ -1008,19 +1019,19 @@ Application.Controllers.controller('ImportMembersController', ['$scope', '$state $scope.method = 'post'; // List of all tags - $scope.tags = tags + $scope.tags = tags; /* * Callback run after the form was submitted - * @param content {*} The result provided by the server, may be an Import object or an error message + * @param content {*} The result provided by the server, may be an Import object, or an error message */ - $scope.onImportResult = function(content) { + $scope.onImportResult = function (content) { if (content.id) { $state.go('app.admin.members_import_result', { id: content.id }); } else { growl.error(JSON.stringify(content)); } - } + }; // Using the MembersController return new MembersController($scope, $state, Group, Training); @@ -1041,7 +1052,7 @@ Application.Controllers.controller('ImportMembersResultController', ['$scope', ' $scope.results = null; /** - * Changes the admin's view to the members import page + * Changes the view of the admin to the members import page */ $scope.cancel = function () { $state.go('app.admin.members_import'); }; @@ -1053,8 +1064,8 @@ Application.Controllers.controller('ImportMembersResultController', ['$scope', ' const initialize = function () { $scope.results = JSON.parse($scope.import.results); if (!$scope.results) { - setTimeout(function() { - Import.get({ id: $scope.import.id }, function(data) { + setTimeout(function () { + Import.get({ id: $scope.import.id }, function (data) { $scope.import = data; initialize(); }); @@ -1068,69 +1079,68 @@ Application.Controllers.controller('ImportMembersResultController', ['$scope', ' ]); /** - * Controller used in the admin's creation page (admin view) + * Controller used in the admin creation page (admin view) */ Application.Controllers.controller('NewAdminController', ['$state', '$scope', 'Admin', 'growl', '_t', 'phoneRequiredPromise', function ($state, $scope, Admin, growl, _t, phoneRequiredPromise) { // default admin profile - let getGender; - $scope.admin = { - statistic_profile_attributes: { - gender: true - }, - profile_attributes: {}, - invoicing_profile_attributes: {} - }; + let getGender; + $scope.admin = { + statistic_profile_attributes: { + gender: true + }, + profile_attributes: {}, + invoicing_profile_attributes: {} + }; - // Default parameters for AngularUI-Bootstrap datepicker - $scope.datePicker = { - format: Fablab.uibDateFormat, - opened: false, - options: { - startingDay: Fablab.weekStartingDay - } - }; + // Default parameters for AngularUI-Bootstrap datepicker + $scope.datePicker = { + format: Fablab.uibDateFormat, + opened: false, + options: { + startingDay: Fablab.weekStartingDay + } + }; - // is the phone number required in _admin_form? - $scope.phoneRequired = (phoneRequiredPromise.setting.value === 'true'); + // is the phone number required in _admin_form? + $scope.phoneRequired = (phoneRequiredPromise.setting.value === 'true'); - /** - * Shows the birth day datepicker - * @param $event {Object} jQuery event object + /** + * Shows the birthday datepicker */ - $scope.openDatePicker = function ($event) { $scope.datePicker.opened = true; }; + $scope.openDatePicker = function () { $scope.datePicker.opened = true; }; - /** + /** * Send the new admin, currently stored in $scope.admin, to the server for database saving */ - $scope.saveAdmin = function () { - Admin.save( - {}, - { admin: $scope.admin }, - function () { - growl.success(_t('app.admin.admins_new.administrator_successfully_created_he_will_receive_his_connection_directives_by_email', { GENDER: getGender($scope.admin) })); - return $state.go('app.admin.members'); - } - , function (error) { - growl.error(_t('app.admin.admins_new.failed_to_create_admin') + JSON.stringify(error.data ? error.data : error)); - console.error(error); - } - ); - }; + $scope.saveAdmin = function () { + Admin.save( + {}, + { admin: $scope.admin }, + function () { + growl.success(_t('app.admin.admins_new.administrator_successfully_created_he_will_receive_his_connection_directives_by_email', { GENDER: getGender($scope.admin) })); + return $state.go('app.admin.members'); + } + , function (error) { + growl.error(_t('app.admin.admins_new.failed_to_create_admin') + JSON.stringify(error.data ? error.data : error)); + console.error(error); + } + ); + }; - /* PRIVATE SCOPE */ + /* PRIVATE SCOPE */ - /** + /** * Return an enumerable meaningful string for the gender of the provider user * @param user {Object} Database user record * @return {string} 'male' or 'female' */ - return getGender = function (user) { - if (user.statistic_profile_attributes) { - if (user.statistic_profile_attributes.gender) { return 'male'; } else { return 'female'; } - } else { return 'other'; } - }; -} + return getGender = function (user) { + if (user.statistic_profile_attributes) { + if (user.statistic_profile_attributes.gender) { return 'male'; } else { return 'female'; } + } else { return 'other'; } + }; + } ]); @@ -1140,65 +1150,64 @@ Application.Controllers.controller('NewAdminController', ['$state', '$scope', 'A Application.Controllers.controller('NewManagerController', ['$state', '$scope', 'User', 'groupsPromise', 'tagsPromise', 'growl', '_t', function ($state, $scope, User, groupsPromise, tagsPromise, growl, _t) { // default admin profile - $scope.manager = { - statistic_profile_attributes: { - gender: true - }, - profile_attributes: {}, - invoicing_profile_attributes: {} - }; + $scope.manager = { + statistic_profile_attributes: { + gender: true + }, + profile_attributes: {}, + invoicing_profile_attributes: {} + }; - // Default parameters for AngularUI-Bootstrap datepicker - $scope.datePicker = { - format: Fablab.uibDateFormat, - opened: false, - options: { - startingDay: Fablab.weekStartingDay - } - }; + // Default parameters for AngularUI-Bootstrap datepicker + $scope.datePicker = { + format: Fablab.uibDateFormat, + opened: false, + options: { + startingDay: Fablab.weekStartingDay + } + }; - // list of all groups - $scope.groups = groupsPromise.filter(function (g) { return (g.slug !== 'admins') && !g.disabled; }); + // list of all groups + $scope.groups = groupsPromise.filter(function (g) { return (g.slug !== 'admins') && !g.disabled; }); - // list of all tags - $scope.tags = tagsPromise; + // list of all tags + $scope.tags = tagsPromise; - /** - * Shows the birth day datepicker - * @param $event {Object} jQuery event object + /** + * Shows the birthday datepicker */ - $scope.openDatePicker = function ($event) { $scope.datePicker.opened = true; }; + $scope.openDatePicker = function () { $scope.datePicker.opened = true; }; - /** + /** * Send the new manager, currently stored in $scope.manager, to the server for database saving */ - $scope.saveManager = function () { - User.save( - {}, - { manager: $scope.manager }, - function () { - growl.success(_t('app.admin.manager_new.manager_successfully_created', { GENDER: getGender($scope.manager) })); - return $state.go('app.admin.members'); - } - , function (error) { - growl.error(_t('app.admin.admins_new.failed_to_create_manager') + JSON.stringify(error.data ? error.data : error)); - console.error(error); - } - ); - }; + $scope.saveManager = function () { + User.save( + {}, + { manager: $scope.manager }, + function () { + growl.success(_t('app.admin.manager_new.manager_successfully_created', { GENDER: getGender($scope.manager) })); + return $state.go('app.admin.members'); + } + , function (error) { + growl.error(_t('app.admin.admins_new.failed_to_create_manager') + JSON.stringify(error.data ? error.data : error)); + console.error(error); + } + ); + }; - /* PRIVATE SCOPE */ + /* PRIVATE SCOPE */ - /** + /** * Return an enumerable meaningful string for the gender of the provider user * @param user {Object} Database user record * @return {string} 'male' or 'female' */ - const getGender = function (user) { - if (user.statistic_profile_attributes) { - if (user.statistic_profile_attributes.gender) { return 'male'; } else { return 'female'; } - } else { return 'other'; } - }; -} + const getGender = function (user) { + if (user.statistic_profile_attributes) { + if (user.statistic_profile_attributes.gender) { return 'male'; } else { return 'female'; } + } else { return 'other'; } + }; + } ]); From 609d19e5d17519b1360957084414ddeea7920cef Mon Sep 17 00:00:00 2001 From: Sylvain Date: Mon, 21 Dec 2020 16:12:34 +0100 Subject: [PATCH 9/9] refactored subscription process + renew (update) subscription/ offre free days --- app/controllers/api/payments_controller.rb | 9 +- .../api/subscriptions_controller.rb | 9 +- app/frontend/src/javascript/api/api-client.ts | 7 ++ .../javascript/controllers/admin/members.js | 1 - .../src/javascript/directives/cart.js | 31 ++++--- .../admin/subscriptions/expired_at_modal.html | 1 + app/models/subscription.rb | 83 +------------------ app/services/reservations/reserve.rb | 33 +------- app/services/subscriptions/subscribe.rb | 72 +++++++++++++--- app/services/wallet_service.rb | 30 +++++++ config/locales/app.admin.en.yml | 1 + config/locales/app.admin.fr.yml | 1 + 12 files changed, 134 insertions(+), 144 deletions(-) diff --git a/app/controllers/api/payments_controller.rb b/app/controllers/api/payments_controller.rb index 2bc906e1f..ad1eaa2dd 100644 --- a/app/controllers/api/payments_controller.rb +++ b/app/controllers/api/payments_controller.rb @@ -49,7 +49,7 @@ class API::PaymentsController < API::ApiController if params[:cart_items][:reservation] res = on_reservation_success(intent, amount[:details]) elsif params[:cart_items][:subscription] - res = on_subscription_success(intent) + res = on_subscription_success(intent, amount[:details]) end end @@ -84,7 +84,7 @@ class API::PaymentsController < API::ApiController if params[:cart_items][:reservation] res = on_reservation_success(intent, amount[:details]) elsif params[:cart_items][:subscription] - res = on_subscription_success(intent) + res = on_subscription_success(intent, amount[:details]) end end @@ -125,7 +125,7 @@ class API::PaymentsController < API::ApiController end end - def on_subscription_success(intent) + def on_subscription_success(intent, details) @subscription = Subscription.new(subscription_params) user_id = if current_user.admin? || current_user.manager? params[:cart_items][:subscription][:user_id] @@ -134,8 +134,7 @@ class API::PaymentsController < API::ApiController end is_subscribe = Subscriptions::Subscribe.new(current_user.invoicing_profile.id, user_id) .pay_and_save(@subscription, - coupon: coupon_params[:coupon_code], - invoice: true, + payment_details: details, payment_intent_id: intent.id, schedule: params[:cart_items][:subscription][:payment_schedule], payment_method: 'stripe') diff --git a/app/controllers/api/subscriptions_controller.rb b/app/controllers/api/subscriptions_controller.rb index f79017acd..db8c1ce12 100644 --- a/app/controllers/api/subscriptions_controller.rb +++ b/app/controllers/api/subscriptions_controller.rb @@ -14,14 +14,13 @@ class API::SubscriptionsController < API::ApiController # Managers can create subscriptions for other users def create user_id = current_user.admin? || current_user.manager? ? params[:subscription][:user_id] : current_user.id - amount = transaction_amount(current_user.admin? || (current_user.manager? && current_user.id != user_id), user_id) + transaction = transaction_amount(current_user.admin? || (current_user.manager? && current_user.id != user_id), user_id) - authorize SubscriptionContext.new(Subscription, amount, user_id) + authorize SubscriptionContext.new(Subscription, transaction[:amount], user_id) @subscription = Subscription.new(subscription_params) is_subscribe = Subscriptions::Subscribe.new(current_user.invoicing_profile.id, user_id) - .pay_and_save(@subscription, coupon: coupon_params[:coupon_code], - invoice: true, + .pay_and_save(@subscription, payment_details: transaction[:details], schedule: params[:subscription][:payment_schedule], payment_method: params[:subscription][:payment_method]) @@ -65,7 +64,7 @@ class API::SubscriptionsController < API::ApiController # Subtract wallet amount from total total = price_details[:total] wallet_debit = get_wallet_debit(user, total) - total - wallet_debit + { amount: total - wallet_debit, details: price_details } end def get_wallet_debit(user, total_amount) diff --git a/app/frontend/src/javascript/api/api-client.ts b/app/frontend/src/javascript/api/api-client.ts index ad4aa60f2..eaead8232 100644 --- a/app/frontend/src/javascript/api/api-client.ts +++ b/app/frontend/src/javascript/api/api-client.ts @@ -19,6 +19,13 @@ client.interceptors.response.use(function (response) { }); function extractHumanReadableMessage(error: any): string { + if (error.match(/^/)) { + // parse ruby error pages + const parser = new DOMParser(); + const htmlDoc = parser.parseFromString(error, 'text/html'); + return htmlDoc.querySelector('h2').textContent; + } + if (typeof error === 'string') return error; let message = ''; diff --git a/app/frontend/src/javascript/controllers/admin/members.js b/app/frontend/src/javascript/controllers/admin/members.js index 114502eae..3f105e484 100644 --- a/app/frontend/src/javascript/controllers/admin/members.js +++ b/app/frontend/src/javascript/controllers/admin/members.js @@ -761,7 +761,6 @@ Application.Controllers.controller('EditMemberController', ['$scope', '$state', size: 'lg', controller: ['$scope', '$uibModalInstance', 'Subscription', function ($scope, $uibModalInstance, Subscription) { $scope.new_expired_at = angular.copy(subscription.expired_at); - $scope.scheduled = subscription.scheduled; $scope.free = free; $scope.datePicker = { opened: false, diff --git a/app/frontend/src/javascript/directives/cart.js b/app/frontend/src/javascript/directives/cart.js index dea22cab0..a209e394a 100644 --- a/app/frontend/src/javascript/directives/cart.js +++ b/app/frontend/src/javascript/directives/cart.js @@ -716,9 +716,14 @@ Application.Directives.directive('cart', ['$rootScope', '$uibModal', 'dialogs', * Open a modal window that allows the user to process a credit card payment for his current shopping cart. */ const payByStripe = function (reservation) { - $scope.toggleStripeModal(() => { - $scope.stripe.cartItems = mkCartItems(reservation, 'stripe'); - }); + // check that the online payment is enabled + if ($scope.settings.online_payment_module !== 'true') { + growl.error(_t('app.shared.cart.online_payment_disabled')); + } else { + $scope.toggleStripeModal(() => { + $scope.stripe.cartItems = mkCartItems(reservation, 'stripe'); + }); + } }; /** * Open a modal window that allows the user to process a local payment for his current shopping cart (admin only). @@ -751,10 +756,13 @@ Application.Directives.directive('cart', ['$rootScope', '$uibModal', 'dialogs', }, user () { return $scope.user; + }, + settings () { + return $scope.settings; } }, - controller: ['$scope', '$uibModalInstance', '$state', 'reservation', 'price', 'Auth', 'Reservation', 'Subscription', 'wallet', 'helpers', '$filter', 'coupon', 'selectedPlan', 'schedule', 'cartItems', 'user', - function ($scope, $uibModalInstance, $state, reservation, price, Auth, Reservation, Subscription, wallet, helpers, $filter, coupon, selectedPlan, schedule, cartItems, user) { + controller: ['$scope', '$uibModalInstance', '$state', 'reservation', 'price', 'Auth', 'Reservation', 'Subscription', 'wallet', 'helpers', '$filter', 'coupon', 'selectedPlan', 'schedule', 'cartItems', 'user', 'settings', + function ($scope, $uibModalInstance, $state, reservation, price, Auth, Reservation, Subscription, wallet, helpers, $filter, coupon, selectedPlan, schedule, cartItems, user, settings) { // user wallet amount $scope.wallet = wallet; @@ -797,7 +805,12 @@ Application.Directives.directive('cart', ['$rootScope', '$uibModal', 'dialogs', */ $scope.ok = function () { if ($scope.schedule && $scope.method.payment_method === 'stripe') { - return $scope.toggleStripeModal(); + // check that the online payment is enabled + if (settings.online_payment_module !== 'true') { + return growl.error(_t('app.shared.cart.online_payment_disabled')); + } else { + return $scope.toggleStripeModal(); + } } $scope.attempting = true; // save subscription (if there's only a subscription selected) @@ -927,11 +940,7 @@ Application.Directives.directive('cart', ['$rootScope', '$uibModal', 'dialogs', const amountToPay = helpers.getAmountToPay($scope.amountTotal, wallet.amount); if ((AuthService.isAuthorized(['member']) && amountToPay > 0) || (AuthService.isAuthorized('manager') && $scope.user.id === $rootScope.currentUser.id && amountToPay > 0)) { - if ($scope.settings.online_payment_module !== 'true') { - growl.error(_t('app.shared.cart.online_payment_disabled')); - } else { - return payByStripe(reservation); - } + return payByStripe(reservation); } else { if (AuthService.isAuthorized(['admin']) || (AuthService.isAuthorized('manager') && $scope.user.id !== $rootScope.currentUser.id) || diff --git a/app/frontend/templates/admin/subscriptions/expired_at_modal.html b/app/frontend/templates/admin/subscriptions/expired_at_modal.html index 6d02cd49f..02e0b71ac 100644 --- a/app/frontend/templates/admin/subscriptions/expired_at_modal.html +++ b/app/frontend/templates/admin/subscriptions/expired_at_modal.html @@ -10,6 +10,7 @@

{{ 'app.admin.members_edit.you_intentionally_decide_to_extend_the_user_s_subscription_by_charging_him_again_for_his_current_subscription' }}

{{ 'app.admin.members_edit.credits_will_be_reset' }}

+

{{ 'app.admin.members_edit.payment_scheduled' }}

diff --git a/app/models/subscription.rb b/app/models/subscription.rb index 540396345..f47a82816 100644 --- a/app/models/subscription.rb +++ b/app/models/subscription.rb @@ -20,8 +20,7 @@ class Subscription < ApplicationRecord after_save :notify_partner_subscribed_plan, if: :of_partner_plan? ## - # Set the inner properties of the subscription, init the user's credits and save the subscription - # into the DB + # Set the inner properties of the subscription, init the user's credits and save the subscription into the DB # @return {boolean} true, if the operation succeeded ## def init_save @@ -34,86 +33,6 @@ class Subscription < ApplicationRecord true end - # TODO, remove this method, refactor like services/Reservations::Reserve - # @param invoice if true then only the subscription is payed, without reservation - # if false then the subscription is payed with reservation - # @param payment_method is only used for schedules - def save_with_payment(operator_profile_id, invoice: true, coupon_code: nil, payment_intent_id: nil, schedule: nil, payment_method: nil) - return false unless valid? - - set_expiration_date - return false unless save - - UsersCredits::Manager.new(user: user).reset_credits - if invoice - @wallet_amount_debit = get_wallet_amount_debit - - # debit wallet - wallet_transaction = debit_user_wallet - - payment = if schedule - generate_schedule(operator_profile_id, payment_method, coupon_code) - else - generate_invoice(operator_profile_id, coupon_code, payment_intent_id) - end - - if wallet_transaction - payment.wallet_amount = @wallet_amount_debit - payment.wallet_transaction_id = wallet_transaction.id - end - payment.save - end - true - end - - def generate_schedule(operator_profile_id, payment_method, coupon_code = nil) - operator = InvoicingProfile.find(operator_profile_id)&.user - coupon = Coupon.find_by(code: coupon_code) unless coupon_code.nil? - - PaymentScheduleService.new.create( - self, - plan.amount, - coupon: coupon, - operator: operator, - payment_method: payment_method, - user: user - ) - end - - def generate_invoice(operator_profile_id, coupon_code = nil, payment_intent_id = nil) - coupon_id = nil - total = plan.amount - operator = InvoicingProfile.find(operator_profile_id)&.user - method = operator&.admin? || (operator&.manager? && operator != user) ? nil : 'stripe' - - unless coupon_code.nil? - @coupon = Coupon.find_by(code: coupon_code) - - unless @coupon.nil? - total = CouponService.new.apply(plan.amount, @coupon, user.id) - coupon_id = @coupon.id - end - end - - invoice = Invoice.new( - invoiced_id: id, - invoiced_type: 'Subscription', - invoicing_profile: user.invoicing_profile, - statistic_profile: user.statistic_profile, - total: total, - coupon_id: coupon_id, - operator_profile_id: operator_profile_id, - stp_payment_intent_id: payment_intent_id, - payment_method: method - ) - invoice.invoice_items.push InvoiceItem.new( - amount: plan.amount, - description: plan.name, - subscription_id: id - ) - invoice - end - def generate_and_save_invoice(operator_profile_id) generate_invoice(operator_profile_id).save end diff --git a/app/services/reservations/reserve.rb b/app/services/reservations/reserve.rb index 77599952b..e2bf3d225 100644 --- a/app/services/reservations/reserve.rb +++ b/app/services/reservations/reserve.rb @@ -29,11 +29,13 @@ class Reservations::Reserve generate_invoice(reservation, operator_profile_id, payment_details, payment_intent_id) end payment.save - debit_user_wallet(payment, user, reservation) + WalletService.debit_user_wallet(payment, user, reservation) reservation.post_save true end + private + ## # Generate the invoice for the given reservation+subscription ## @@ -64,33 +66,4 @@ class Reservations::Reserve ) end - ## - # Compute the amount decreased from the user's wallet, if applicable - # @param payment {Invoice|PaymentSchedule} - # @param user {User} the customer - # @param coupon {Coupon|String} Coupon object or code - ## - def wallet_amount_debit(payment, user, coupon = nil) - total = payment.total - total = CouponService.new.apply(total, coupon, user.id) if coupon - - wallet_amount = (user.wallet.amount * 100).to_i - - wallet_amount >= total ? total : wallet_amount - end - - ## - # Subtract the amount of the current reservation from the customer's wallet - ## - def debit_user_wallet(payment, user, reservation) - wallet_amount = wallet_amount_debit(payment, user) - return unless wallet_amount.present? && wallet_amount != 0 - - amount = wallet_amount / 100.0 - wallet_transaction = WalletService.new(user: user, wallet: user.wallet).debit(amount, reservation) - # wallet debit success - raise DebitWalletError unless wallet_transaction - - payment.set_wallet_transaction(wallet_amount, wallet_transaction.id) - end end diff --git a/app/services/subscriptions/subscribe.rb b/app/services/subscriptions/subscribe.rb index bcfc9fc82..56b7ad921 100644 --- a/app/services/subscriptions/subscribe.rb +++ b/app/services/subscriptions/subscribe.rb @@ -11,22 +11,31 @@ class Subscriptions::Subscribe ## # @param subscription {Subscription} - # @param coupon {String} coupon code - # @param invoice {Boolean} + # @param payment_details {Hash} as generated by Price.compute # @param payment_intent_id {String} from stripe # @param schedule {Boolean} # @param payment_method {String} only for schedules ## - def pay_and_save(subscription, coupon: nil, invoice: false, payment_intent_id: nil, schedule: false, payment_method: nil) + def pay_and_save(subscription, payment_details: nil, payment_intent_id: nil, schedule: false, payment_method: nil) return false if user_id.nil? subscription.statistic_profile_id = StatisticProfile.find_by(user_id: user_id).id - subscription.save_with_payment(operator_profile_id, - invoice: invoice, - coupon_code: coupon, - payment_intent_id: payment_intent_id, - schedule: schedule, - payment_method: payment_method) + subscription.init_save + user = User.find(user_id) + + payment = if schedule + generate_schedule(subscription: subscription, + total: payment_details[:before_coupon], + operator_profile_id: operator_profile_id, + user: user, + payment_method: payment_method, + coupon_code: payment_details[:coupon]) + else + generate_invoice(subscription, operator_profile_id, payment_details, payment_intent_id) + end + payment.save + WalletService.debit_user_wallet(payment, user, subscription) + true end def extend_subscription(subscription, new_expiration_date, free_days) @@ -38,10 +47,53 @@ class Subscriptions::Subscribe expiration_date: new_expiration_date ) if new_sub.save - new_sub.user.generate_subscription_invoice(operator_profile_id) + schedule = subscription.payment_schedule + details = Price.compute(true, new_sub.user, nil, [], plan_id: subscription.plan_id) + payment = if schedule + generate_schedule(subscription: new_sub, + total: details[:before_coupon], + operator_profile_id: operator_profile_id, + user: new_sub.user, + payment_method: schedule.payment_method) + else + generate_invoice(subscription, operator_profile_id, details) + end + payment.save UsersCredits::Manager.new(user: new_sub.user).reset_credits return new_sub end false end + + private + + ## + # Generate the invoice for the given subscription + ## + def generate_schedule(subscription: nil, total: nil, operator_profile_id: nil, user: nil, payment_method: nil, coupon_code: nil) + operator = InvoicingProfile.find(operator_profile_id)&.user + coupon = Coupon.find_by(code: coupon_code) unless coupon_code.nil? + + PaymentScheduleService.new.create( + subscription, + total, + coupon: coupon, + operator: operator, + payment_method: payment_method, + user: user + ) + end + + ## + # Generate the invoice for the given subscription + ## + def generate_invoice(subscription, operator_profile_id, payment_details, payment_intent_id = nil) + InvoicesService.create( + payment_details, + operator_profile_id, + subscription: subscription, + payment_intent_id: payment_intent_id + ) + end + end diff --git a/app/services/wallet_service.rb b/app/services/wallet_service.rb index cdde0cef7..87ad0348f 100644 --- a/app/services/wallet_service.rb +++ b/app/services/wallet_service.rb @@ -72,4 +72,34 @@ class WalletService ii.invoice = avoir ii.save! end + + ## + # Compute the amount decreased from the user's wallet, if applicable + # @param payment {Invoice|PaymentSchedule} + # @param user {User} the customer + # @param coupon {Coupon|String} Coupon object or code + ## + def self.wallet_amount_debit(payment, user, coupon = nil) + total = payment.total + total = CouponService.new.apply(total, coupon, user.id) if coupon + + wallet_amount = (user.wallet.amount * 100).to_i + + wallet_amount >= total ? total : wallet_amount + end + + ## + # Subtract the amount of the transactable item (Subscription|Reservation) from the customer's wallet + ## + def self.debit_user_wallet(payment, user, transactable) + wallet_amount = WalletService.wallet_amount_debit(payment, user) + return unless wallet_amount.present? && wallet_amount != 0 + + amount = wallet_amount / 100.0 + wallet_transaction = WalletService.new(user: user, wallet: user.wallet).debit(amount, transactable) + # wallet debit success + raise DebitWalletError unless wallet_transaction + + payment.set_wallet_transaction(wallet_amount, wallet_transaction.id) + end end diff --git a/config/locales/app.admin.en.yml b/config/locales/app.admin.en.yml index 2b0e33058..788c2f3d4 100644 --- a/config/locales/app.admin.en.yml +++ b/config/locales/app.admin.en.yml @@ -815,6 +815,7 @@ en: credits_will_remain_unchanged: "The balance of free credits (training / machines / spaces) of the user will remain unchanged." you_intentionally_decide_to_extend_the_user_s_subscription_by_charging_him_again_for_his_current_subscription: "You intentionally decide to extend the user's subscription by charging him again for his current subscription." credits_will_be_reset: "The balance of free credits (training / machines / spaces) of the user will be reset, unused credits will be lost." + payment_scheduled: "If the previous subscription was charged through a payment schedule, this one will be charged the same way." until_expiration_date: "Until (expiration date):" you_successfully_changed_the_expiration_date_of_the_user_s_subscription: "You successfully changed the expiration date of the user's subscription" a_problem_occurred_while_saving_the_date: "A problem occurred while saving the date." diff --git a/config/locales/app.admin.fr.yml b/config/locales/app.admin.fr.yml index cdb80eac5..69d3ab126 100644 --- a/config/locales/app.admin.fr.yml +++ b/config/locales/app.admin.fr.yml @@ -815,6 +815,7 @@ fr: credits_will_remain_unchanged: "Le solde de crédits gratuits (formations/machines/espaces) de l'utilisateur restera inchangé." you_intentionally_decide_to_extend_the_user_s_subscription_by_charging_him_again_for_his_current_subscription: "Vous décidez délibérément d'étendre l'abonnement de l'utilisateur en lui faisant repayer le prix de l'abonnement qu'il possède actuellement." credits_will_be_reset: "Le solde de crédits gratuits (formations/machines/espaces) de l'utilisateur sera remis à zéro, ses crédits non utilisés seront perdu." + payment_scheduled: "Si l'abonnement précédent a été facturé via un échéancier de paiement mensualisé, celui-ci sera facturé de la même façon." until_expiration_date: "Jusqu'à (date d'expiration) :" you_successfully_changed_the_expiration_date_of_the_user_s_subscription: "Vous avez bien modifié la date d'expiration de l'abonnement de l'utilisateur" a_problem_occurred_while_saving_the_date: "Il y a eu un problème lors de l'enregistrement de la date."