diff --git a/Procfile b/Procfile index bbda6f44f..e68a73938 100644 --- a/Procfile +++ b/Procfile @@ -1,4 +1,4 @@ -#web: bundle exec rails server puma -p $PORT +web: bundle exec rails server puma -p $PORT worker: bundle exec sidekiq -C ./config/sidekiq.yml wp-client: bin/webpack-dev-server wp-server: SERVER_BUNDLE_ONLY=yes bin/webpack --watch diff --git a/app/frontend/src/javascript/directives/cart.js b/app/frontend/src/javascript/directives/cart.js index a209e394a..6510b643e 100644 --- a/app/frontend/src/javascript/directives/cart.js +++ b/app/frontend/src/javascript/directives/cart.js @@ -938,7 +938,7 @@ Application.Directives.directive('cart', ['$rootScope', '$uibModal', 'dialogs', return Wallet.getWalletByUser({ user_id: $scope.user.id }, function (wallet) { const amountToPay = helpers.getAmountToPay($scope.amountTotal, wallet.amount); - if ((AuthService.isAuthorized(['member']) && amountToPay > 0) || + if ((AuthService.isAuthorized(['member']) && (amountToPay > 0 || (amountToPay === 0 && hasOtherDeadlines()))) || (AuthService.isAuthorized('manager') && $scope.user.id === $rootScope.currentUser.id && amountToPay > 0)) { return payByStripe(reservation); } else { diff --git a/app/frontend/src/stylesheets/modules/fab-modal.scss b/app/frontend/src/stylesheets/modules/fab-modal.scss index 7eb775fd2..f81344e64 100644 --- a/app/frontend/src/stylesheets/modules/fab-modal.scss +++ b/app/frontend/src/stylesheets/modules/fab-modal.scss @@ -17,6 +17,8 @@ bottom: 0; background-color: rgba(0, 0, 0, 0.9); animation: 0.15s linear fadeIn; + overflow-x: hidden; + overflow-y: auto; } .fab-modal-sm { width: 340px; } diff --git a/app/frontend/templates/admin/coupons/_form.html b/app/frontend/templates/admin/coupons/_form.html index 9cc475d0a..f326788e2 100644 --- a/app/frontend/templates/admin/coupons/_form.html +++ b/app/frontend/templates/admin/coupons/_form.html @@ -79,6 +79,8 @@ {{ 'app.shared.coupon.validity_per_user_is_required' }} +

{{ 'app.shared.coupon.warn_validity_once' }}

+

{{ 'app.shared.coupon.warn_validity_forever' }}

diff --git a/app/frontend/templates/wallet/transactions.html b/app/frontend/templates/wallet/transactions.html index e8c90359e..edd5b5151 100644 --- a/app/frontend/templates/wallet/transactions.html +++ b/app/frontend/templates/wallet/transactions.html @@ -17,6 +17,9 @@ {{::t.invoice.reference}} + + {{::t.payment_schedule.reference}} + {{ ::t.user.full_name }} diff --git a/app/models/coupon.rb b/app/models/coupon.rb index 4e0a7d13b..e8f0194ae 100644 --- a/app/models/coupon.rb +++ b/app/models/coupon.rb @@ -32,13 +32,17 @@ class Coupon < ApplicationRecord } def safe_destroy - if invoices.size.zero? + if usages.zero? destroy else false end end + def usages + invoices.count + payment_schedule.count + end + ## # Check the status of the current coupon. The coupon: # - may have been disabled by an admin, diff --git a/app/models/subscription.rb b/app/models/subscription.rb index d9e69313b..dbbc715ca 100644 --- a/app/models/subscription.rb +++ b/app/models/subscription.rb @@ -38,6 +38,7 @@ class Subscription < ApplicationRecord end def cancel + # TODO, currently unused, refactor to use with PaymentSchedule update_columns(canceled_at: DateTime.current) end diff --git a/app/views/api/coupons/_coupon.json.jbuilder b/app/views/api/coupons/_coupon.json.jbuilder index d4dd4cd82..bc71c5284 100644 --- a/app/views/api/coupons/_coupon.json.jbuilder +++ b/app/views/api/coupons/_coupon.json.jbuilder @@ -1,4 +1,4 @@ json.extract! coupon, :id, :name, :code, :type, :percent_off, :valid_until, :validity_per_user, :max_usages, :active, :created_at json.amount_off (coupon.amount_off / 100.00) unless coupon.amount_off.nil? -json.usages coupon.invoices.count -json.status coupon.status \ No newline at end of file +json.usages coupon.usages +json.status coupon.status diff --git a/app/views/api/wallet/transactions.json.jbuilder b/app/views/api/wallet/transactions.json.jbuilder index eaa955dd2..ba61cf89f 100644 --- a/app/views/api/wallet/transactions.json.jbuilder +++ b/app/views/api/wallet/transactions.json.jbuilder @@ -10,4 +10,10 @@ json.array!(@wallet_transactions) do |t| json.reference t.invoice.reference end end + if t.payment_schedule + json.payment_schedule do + json.id t.payment_schedule.id + json.reference t.payment_schedule.reference + end + end end diff --git a/app/workers/stripe_worker.rb b/app/workers/stripe_worker.rb index 3df6c087b..de5d2da18 100644 --- a/app/workers/stripe_worker.rb +++ b/app/workers/stripe_worker.rb @@ -49,5 +49,19 @@ class StripeWorker object.update_attributes(stp_product_id: product.id) puts "Stripe product was created for the #{class_name} \##{id}" end + + rescue Stripe::InvalidRequestError + STDERR.puts "WARNING: saved stp_product_id (#{object.stp_product_id}) does not match on Stripe, recreating..." + product = Stripe::Product.create( + { + name: object.name, + metadata: { + id: object.id, + type: class_name + } + }, { api_key: Setting.get('stripe_secret_key') } + ) + object.update_attributes(stp_product_id: product.id) + puts "Stripe product was created for the #{class_name} \##{id}" end end diff --git a/config/application.rb b/config/application.rb index ef31793ab..8f6eafc0c 100644 --- a/config/application.rb +++ b/config/application.rb @@ -42,11 +42,6 @@ module Fablab # The default locale is :en and all translations from config/locales/*.rb,yml are auto loaded. # config.i18n.load_path += Dir[Rails.root.join('my', 'locales', '*.{rb,yml}').to_s] - # - # /!\ ALL locales SHOULD be configured accordingly with this locale. /!\ - # - config.i18n.default_locale = Rails.application.secrets.rails_locale - config.i18n.fallbacks = [Rails.application.secrets.app_locale, :en] config.to_prepare do Devise::Mailer.layout 'notifications_mailer' diff --git a/config/initializers/locale.rb b/config/initializers/locale.rb index 6cab93747..719b8fc60 100644 --- a/config/initializers/locale.rb +++ b/config/initializers/locale.rb @@ -5,3 +5,10 @@ I18n.config.available_locales += %i[en en-AU-CA en-GB en-IE en-IN en-NZ en-US en es-AR es-CL es-CO es-CR es-DO es-EC es-ES es-MX es-PA es-PE es-US es-VE pt pt-BR zu] # we allow the Zulu locale (zu) as it is used for In-Context translation # @see https://support.crowdin.com/in-context-localization/ + + +# +# /!\ ALL locales SHOULD be configured accordingly with the default_locale. /!\ +# +I18n.config.default_locale = Rails.application.secrets.rails_locale +I18n.config.locale = Rails.application.secrets.rails_locale diff --git a/config/locales/app.admin.en.yml b/config/locales/app.admin.en.yml index 788c2f3d4..2a50f2f24 100644 --- a/config/locales/app.admin.en.yml +++ b/config/locales/app.admin.en.yml @@ -346,7 +346,7 @@ en: confirmation_required: "Confirmation required" do_you_really_want_to_delete_this_coupon: "Do you really want to delete this coupon?" coupon_was_successfully_deleted: "Coupon was successfully deleted." - unable_to_delete_the_specified_coupon_already_in_use: "Unable to delete the specified coupon: it is already used with some invoices." + unable_to_delete_the_specified_coupon_already_in_use: "Unable to delete the specified coupon: it is already used with some invoices and/or some payment schedules." unable_to_delete_the_specified_coupon_an_unexpected_error_occurred: "Unable to delete the specified coupon: an unexpected error occurred." send_a_coupon: "Send a coupon" coupon: "Coupon" diff --git a/config/locales/app.admin.fr.yml b/config/locales/app.admin.fr.yml index 69d3ab126..dca537f62 100644 --- a/config/locales/app.admin.fr.yml +++ b/config/locales/app.admin.fr.yml @@ -346,7 +346,7 @@ fr: confirmation_required: "Confirmation requise" do_you_really_want_to_delete_this_coupon: "Êtes-vous sûr(e) de vouloir supprimer ce code promotionnel ?" coupon_was_successfully_deleted: "Le code promotionnel a bien été supprimé." - unable_to_delete_the_specified_coupon_already_in_use: "Impossible de supprimer le code promotionnel : il est utilisé dans des factures." + unable_to_delete_the_specified_coupon_already_in_use: "Impossible de supprimer le code promotionnel : il est utilisé dans des factures et/ou des échéanciers." unable_to_delete_the_specified_coupon_an_unexpected_error_occurred: "Impossible de supprimer le code promotionnel : une erreur inattendue s'est produite." send_a_coupon: "Envoyer un code promo" coupon: "Code promo" diff --git a/config/routes.rb b/config/routes.rb index 050864006..3c9131d96 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -111,6 +111,10 @@ Rails.application.routes.draw do get 'first', action: 'first', on: :collection end + resources :payment_schedules, only: %i[index show] do + get 'download', on: :member + end + resources :i_calendar, only: %i[index create destroy] do get 'events', on: :member post 'sync', on: :member diff --git a/lib/tasks/fablab/stripe.rake b/lib/tasks/fablab/stripe.rake index 082d67d98..2c605eafe 100644 --- a/lib/tasks/fablab/stripe.rake +++ b/lib/tasks/fablab/stripe.rake @@ -4,15 +4,6 @@ namespace :fablab do namespace :stripe do - desc 'Cancel stripe subscriptions' - task cancel_subscriptions: :environment do - Subscription.where('expiration_date >= ?', DateTime.current.at_beginning_of_day).each do |s| - puts "-> Start cancel subscription of #{s.user.email}" - s.cancel - puts '-> Done' - end - end - desc 'find any invoices with incoherent total between stripe and DB' task :find_incoherent_invoices, [:start_date] => :environment do |_task, args| puts 'DEPRECATION WARNING: Will not work for invoices created from version 4.1.0 and above' diff --git a/test/fixtures/coupons.yml b/test/fixtures/coupons.yml index f2d485c34..2bf8adab7 100644 --- a/test/fixtures/coupons.yml +++ b/test/fixtures/coupons.yml @@ -5,7 +5,7 @@ one: code: XMAS10 percent_off: 10 valid_until: 2015-12-31 23:59:59 - max_usages: nil + max_usages: active: true validity_per_user: once @@ -32,6 +32,6 @@ cash2: code: GIME3EUR amount_off: 300 valid_until: <%= 1.year.from_now.utc.strftime('%Y-%m-%d %H:%M:%S.%9N Z') %> - max_usages: nil + max_usages: active: true validity_per_user: once diff --git a/test/fixtures/prices.yml b/test/fixtures/prices.yml index e8f08d9ff..0e7ac37cd 100644 --- a/test/fixtures/prices.yml +++ b/test/fixtures/prices.yml @@ -2,7 +2,7 @@ price_1: id: 1 group_id: 1 - plan_id: + plan_id: priceable_id: 1 priceable_type: Machine amount: 2400 @@ -12,7 +12,7 @@ price_1: price_2: id: 2 group_id: 2 - plan_id: + plan_id: priceable_id: 1 priceable_type: Machine amount: 5300 @@ -22,7 +22,7 @@ price_2: price_5: id: 5 group_id: 1 - plan_id: + plan_id: priceable_id: 2 priceable_type: Machine amount: 4200 @@ -32,7 +32,7 @@ price_5: price_6: id: 6 group_id: 2 - plan_id: + plan_id: priceable_id: 2 priceable_type: Machine amount: 1100 @@ -42,7 +42,7 @@ price_6: price_9: id: 9 group_id: 1 - plan_id: + plan_id: priceable_id: 3 priceable_type: Machine amount: 4100 @@ -52,7 +52,7 @@ price_9: price_10: id: 10 group_id: 2 - plan_id: + plan_id: priceable_id: 3 priceable_type: Machine amount: 5300 @@ -62,7 +62,7 @@ price_10: price_13: id: 13 group_id: 1 - plan_id: + plan_id: priceable_id: 4 priceable_type: Machine amount: 900 @@ -72,7 +72,7 @@ price_13: price_14: id: 14 group_id: 2 - plan_id: + plan_id: priceable_id: 4 priceable_type: Machine amount: 5100 @@ -82,7 +82,7 @@ price_14: price_17: id: 17 group_id: 1 - plan_id: + plan_id: priceable_id: 5 priceable_type: Machine amount: 1600 @@ -92,7 +92,7 @@ price_17: price_18: id: 18 group_id: 2 - plan_id: + plan_id: priceable_id: 5 priceable_type: Machine amount: 2000 @@ -102,7 +102,7 @@ price_18: price_21: id: 21 group_id: 1 - plan_id: + plan_id: priceable_id: 6 priceable_type: Machine amount: 3200 @@ -112,7 +112,7 @@ price_21: price_22: id: 22 group_id: 2 - plan_id: + plan_id: priceable_id: 6 priceable_type: Machine amount: 3400 @@ -298,3 +298,63 @@ price_42: amount: 1000 created_at: 2016-04-04 15:18:28.860220000 Z updated_at: 2016-04-04 15:18:50.517702000 Z + +price_43: + id: 43 + group_id: 1 + plan_id: 4 + priceable_id: 1 + priceable_type: Machine + amount: 1000 + created_at: 2016-04-04 15:18:28.836899000 Z + updated_at: 2016-04-04 15:18:50.507019000 Z + +price_44: + id: 44 + group_id: 1 + plan_id: 4 + priceable_id: 2 + priceable_type: Machine + amount: 1000 + created_at: 2016-04-04 15:18:28.842674000 Z + updated_at: 2016-04-04 15:18:50.508799000 Z + +price_45: + id: 45 + group_id: 1 + plan_id: 4 + priceable_id: 3 + priceable_type: Machine + amount: 1500 + created_at: 2016-04-04 15:18:28.847736000 Z + updated_at: 2016-04-04 15:18:50.510437000 Z + +price_46: + id: 46 + group_id: 1 + plan_id: 4 + priceable_id: 4 + priceable_type: Machine + amount: 1000 + created_at: 2016-04-04 15:18:28.852783000 Z + updated_at: 2016-04-04 15:18:50.512239000 Z + +price_47: + id: 47 + group_id: 1 + plan_id: 4 + priceable_id: 5 + priceable_type: Machine + amount: 800 + created_at: 2016-04-04 15:18:28.856602000 Z + updated_at: 2016-04-04 15:18:50.514062000 Z + +price_48: + id: 48 + group_id: 1 + plan_id: 4 + priceable_id: 6 + priceable_type: Machine + amount: 1000 + created_at: 2016-04-04 15:18:28.860220000 Z + updated_at: 2016-04-04 15:18:50.517702000 Z diff --git a/test/fixtures/wallet_transactions.yml b/test/fixtures/wallet_transactions.yml index 1f6eba863..6e521df85 100644 --- a/test/fixtures/wallet_transactions.yml +++ b/test/fixtures/wallet_transactions.yml @@ -1,7 +1,13 @@ # Read about fixtures at http://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html transaction1: - invoicing_profile_id: 5 + invoicing_profile_id: 1 wallet: wallet_5 transaction_type: credit amount: 1000 + +transaction2: + invoicing_profile_id: 1 + wallet: wallet_7 + transaction_type: credit + amount: 25500 diff --git a/test/fixtures/wallets.yml b/test/fixtures/wallets.yml index 84521bdbd..5889fa6fa 100644 --- a/test/fixtures/wallets.yml +++ b/test/fixtures/wallets.yml @@ -24,4 +24,4 @@ wallet_1: wallet_7: invoicing_profile_id: 7 - amount: 0 + amount: 25500 diff --git a/test/integration/reservations/create_test.rb b/test/integration/reservations/create_test.rb index 7226d87fd..3ef638bf2 100644 --- a/test/integration/reservations/create_test.rb +++ b/test/integration/reservations/create_test.rb @@ -742,4 +742,115 @@ class Reservations::CreateTest < ActionDispatch::IntegrationTest reservation = json_response(response.body) assert_equal plan.id, reservation[:user][:subscribed_plan][:id], 'subscribed plan does not match' end + + test 'user reserves a machine and renew a subscription with payment schedule and coupon and wallet' do + user = User.find_by(username: 'lseguin') + login_as(user, scope: :user) + + reservations_count = Reservation.count + invoice_count = Invoice.count + invoice_items_count = InvoiceItem.count + subscriptions_count = Subscription.count + user_subscriptions_count = user.subscriptions.count + payment_schedule_count = PaymentSchedule.count + payment_schedule_items_count = PaymentScheduleItem.count + wallet_transactions_count = WalletTransaction.count + + machine = Machine.find(1) + availability = machine.availabilities.last + plan = Plan.find_by(group_id: user.group.id, type: 'Plan', base_name: 'Abonnement mensualisable') + + VCR.use_cassette('reservations_machine_subscription_with_payment_schedule_coupon_wallet') do + 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 (normally, this is done on browser-side) + 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: { + coupon_code: 'GIME3EUR', + reservation: { + plan_id: plan.id, + payment_schedule: true, + 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 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 0, UsersCredit.count, "user's credits were not reset" + 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' + assert_equal wallet_transactions_count + 1, WalletTransaction.count, 'missing the wallet transaction' + + # get the objects + reservation = Reservation.last + subscription = Subscription.last + payment_schedule = PaymentSchedule.last + + # subscription assertions + assert_equal user_subscriptions_count + 1, user.subscriptions.count + assert_equal user, subscription.user + assert_not_nil user.subscribed_plan, "user's subscribed plan was not found" + assert_not_nil user.subscription, "user's subscription was not found" + assert_equal plan.id, user.subscribed_plan.id, "user's plan does not match" + + # reservation assertions + assert reservation.payment_schedule + assert_equal payment_schedule.scheduled, reservation + + # payment schedule assertions + assert_not_nil payment_schedule.reference + assert_equal 'stripe', payment_schedule.payment_method + assert_not_nil payment_schedule.stp_subscription_id + assert_not_nil payment_schedule.stp_setup_intent_id + assert_not_nil payment_schedule.wallet_transaction + assert_equal payment_schedule.ordered_items.first.amount, payment_schedule.wallet_amount + assert_equal Coupon.find_by(code: 'GIME3EUR').id, payment_schedule.coupon_id + assert_equal 'test', payment_schedule.environment + assert payment_schedule.check_footprint + assert_equal user.invoicing_profile.id, payment_schedule.invoicing_profile_id + assert_equal payment_schedule.invoicing_profile_id, payment_schedule.operator_profile_id + + # Check the answer + reservation = json_response(response.body) + assert_equal plan.id, reservation[:user][:subscribed_plan][:id], 'subscribed plan does not match' + end end