From fd39eaf2f152b9c352d5684acf23b1e479bc590c Mon Sep 17 00:00:00 2001 From: Sylvain Date: Fri, 15 Oct 2021 17:31:01 +0200 Subject: [PATCH] fix renew subscription API TODO: - remove old endpoints - bug in UI (start date invalid) - fix tests - payzen --- app/controllers/api/stripe_controller.rb | 9 ++- .../payment/abstract-payment-modal.tsx | 5 +- .../local-payment/local-payment-form.tsx | 6 +- .../local-payment/local-payment-modal.tsx | 13 ++-- .../prepaid-packs/propose-packs-modal.tsx | 1 + .../components/subscriptions/renew-modal.tsx | 3 +- .../src/javascript/directives/cart.js | 11 ++++ app/frontend/templates/shared/_cart.html | 1 + app/models/invoice.rb | 2 +- app/models/payment_document.rb | 2 +- app/models/payment_schedule.rb | 4 +- app/models/shopping_cart.rb | 2 +- app/services/cart_service.rb | 2 +- app/services/payment_gateway_service.rb | 4 +- app/views/api/members/_member.json.jbuilder | 1 + lib/payment/service.rb | 2 +- lib/stripe/service.rb | 59 +++++++++++-------- 17 files changed, 83 insertions(+), 44 deletions(-) diff --git a/app/controllers/api/stripe_controller.rb b/app/controllers/api/stripe_controller.rb index e05e474e9..e29e06a7d 100644 --- a/app/controllers/api/stripe_controller.rb +++ b/app/controllers/api/stripe_controller.rb @@ -84,8 +84,13 @@ class API::StripeController < API::PaymentsController stp_subscription = service.subscribe(method.id, cart) - res = on_payment_success(stp_subscription, cart) if stp_subscription&.status == 'active' - render generate_payment_response(stp_subscription.latest_invoice.payment_intent, 'subscription', res, stp_subscription.id) + res = on_payment_success(stp_subscription, cart) if %w[active not_started].include?(stp_subscription&.status) + intent = if stp_subscription.object == 'subscription_schedule' + nil + elsif stp_subscription.object == 'subscription' + stp_subscription.latest_invoice&.payment_intent + end + render generate_payment_response(intent, 'subscription', res, stp_subscription.id) end def confirm_subscription diff --git a/app/frontend/src/javascript/components/payment/abstract-payment-modal.tsx b/app/frontend/src/javascript/components/payment/abstract-payment-modal.tsx index fae7815e7..77fe27991 100644 --- a/app/frontend/src/javascript/components/payment/abstract-payment-modal.tsx +++ b/app/frontend/src/javascript/components/payment/abstract-payment-modal.tsx @@ -27,6 +27,7 @@ export interface GatewayFormProps { className?: string, paymentSchedule?: PaymentSchedule, cart?: ShoppingCart, + updateCart?: (cart: ShoppingCart) => void, formId: string, } @@ -36,6 +37,7 @@ interface AbstractPaymentModalProps { afterSuccess: (result: Invoice|PaymentSchedule) => void, onError: (message: string) => void, cart: ShoppingCart, + updateCart?: (cart: ShoppingCart) => void, currentUser: User, schedule?: PaymentSchedule, customer: User, @@ -56,7 +58,7 @@ interface AbstractPaymentModalProps { * This component must not be called directly but must be extended for each implemented payment gateway * @see https://reactjs.org/docs/composition-vs-inheritance.html */ -export const AbstractPaymentModal: React.FC = ({ isOpen, toggleModal, afterSuccess, onError, cart, currentUser, schedule, customer, logoFooter, GatewayForm, formId, className, formClassName, title, preventCgv, preventScheduleInfo, modalSize }) => { +export const AbstractPaymentModal: React.FC = ({ isOpen, toggleModal, afterSuccess, onError, cart, updateCart, currentUser, schedule, customer, logoFooter, GatewayForm, formId, className, formClassName, title, preventCgv, preventScheduleInfo, modalSize }) => { // customer's wallet const [wallet, setWallet] = useState(null); // server-computed price with all details @@ -205,6 +207,7 @@ export const AbstractPaymentModal: React.FC = ({ isOp className={`gateway-form ${formClassName || ''}`} formId={formId} cart={cart} + updateCart={updateCart} customer={customer} paymentSchedule={schedule}> {hasErrors() &&
diff --git a/app/frontend/src/javascript/components/payment/local-payment/local-payment-form.tsx b/app/frontend/src/javascript/components/payment/local-payment/local-payment-form.tsx index fae0f482e..1791c46a4 100644 --- a/app/frontend/src/javascript/components/payment/local-payment/local-payment-form.tsx +++ b/app/frontend/src/javascript/components/payment/local-payment/local-payment-form.tsx @@ -24,7 +24,7 @@ type selectOption = { value: scheduleMethod, label: string }; * This is intended for use by privileged users. * The form validation button must be created elsewhere, using the attribute form={formId}. */ -export const LocalPaymentForm: React.FC = ({ onSubmit, onSuccess, onError, children, className, paymentSchedule, cart, customer, operator, formId }) => { +export const LocalPaymentForm: React.FC = ({ onSubmit, onSuccess, onError, children, className, paymentSchedule, cart, updateCart, customer, operator, formId }) => { const { t } = useTranslation('admin'); const [method, setMethod] = useState('check'); @@ -58,9 +58,9 @@ export const LocalPaymentForm: React.FC = ({ onSubmit, onSucce */ const handleUpdateMethod = (option: selectOption) => { if (option.value === 'card') { - cart.payment_method = PaymentMethod.Card; + updateCart(Object.assign({}, cart, { payment_method: PaymentMethod.Card })); } else { - cart.payment_method = PaymentMethod.Other; + updateCart(Object.assign({}, cart, { payment_method: PaymentMethod.Other })); } setMethod(option.value); }; diff --git a/app/frontend/src/javascript/components/payment/local-payment/local-payment-modal.tsx b/app/frontend/src/javascript/components/payment/local-payment/local-payment-modal.tsx index 939d1792f..37690aad3 100644 --- a/app/frontend/src/javascript/components/payment/local-payment/local-payment-modal.tsx +++ b/app/frontend/src/javascript/components/payment/local-payment/local-payment-modal.tsx @@ -19,6 +19,7 @@ interface LocalPaymentModalProps { afterSuccess: (result: Invoice|PaymentSchedule) => void, onError: (message: string) => void, cart: ShoppingCart, + updateCart: (cart: ShoppingCart) => void, currentUser: User, schedule?: PaymentSchedule, customer: User @@ -27,7 +28,7 @@ interface LocalPaymentModalProps { /** * This component enables a privileged user to confirm a local payments. */ -const LocalPaymentModalComponent: React.FC = ({ isOpen, toggleModal, afterSuccess, onError, cart, currentUser, schedule, customer }) => { +const LocalPaymentModalComponent: React.FC = ({ isOpen, toggleModal, afterSuccess, onError, cart, updateCart, currentUser, schedule, customer }) => { const { t } = useTranslation('admin'); /** @@ -44,7 +45,7 @@ const LocalPaymentModalComponent: React.FC = ({ isOpen, /** * Integrates the LocalPaymentForm into the parent AbstractPaymentModal */ - const renderForm: FunctionComponent = ({ onSubmit, onSuccess, onError, operator, className, formId, cart, customer, paymentSchedule, children }) => { + const renderForm: FunctionComponent = ({ onSubmit, onSuccess, onError, operator, className, formId, cart, updateCart, customer, paymentSchedule, children }) => { return ( = ({ isOpen, className={className} formId={formId} cart={cart} + updateCart={updateCart} customer={customer} paymentSchedule={paymentSchedule}> {children} @@ -70,6 +72,7 @@ const LocalPaymentModalComponent: React.FC = ({ isOpen, formClassName="local-payment-form" currentUser={currentUser} cart={cart} + updateCart={updateCart} customer={customer} afterSuccess={afterSuccess} onError={onError} @@ -81,12 +84,12 @@ const LocalPaymentModalComponent: React.FC = ({ isOpen, ); }; -export const LocalPaymentModal: React.FC = ({ isOpen, toggleModal, afterSuccess, onError, currentUser, schedule, cart, customer }) => { +export const LocalPaymentModal: React.FC = ({ isOpen, toggleModal, afterSuccess, onError, currentUser, schedule, cart, updateCart, customer }) => { return ( - + ); }; -Application.Components.component('localPaymentModal', react2angular(LocalPaymentModal, ['isOpen', 'toggleModal', 'afterSuccess', 'onError', 'currentUser', 'schedule', 'cart', 'customer'])); +Application.Components.component('localPaymentModal', react2angular(LocalPaymentModal, ['isOpen', 'toggleModal', 'afterSuccess', 'onError', 'currentUser', 'schedule', 'cart', 'updateCart', 'customer'])); diff --git a/app/frontend/src/javascript/components/prepaid-packs/propose-packs-modal.tsx b/app/frontend/src/javascript/components/prepaid-packs/propose-packs-modal.tsx index bdd90ec6b..a228ccda2 100644 --- a/app/frontend/src/javascript/components/prepaid-packs/propose-packs-modal.tsx +++ b/app/frontend/src/javascript/components/prepaid-packs/propose-packs-modal.tsx @@ -164,6 +164,7 @@ export const ProposePacksModal: React.FC = ({ isOpen, to afterSuccess={handlePackBought} onError={onError} cart={cart} + updateCart={setCart} currentUser={operator} customer={customer} />
} diff --git a/app/frontend/src/javascript/components/subscriptions/renew-modal.tsx b/app/frontend/src/javascript/components/subscriptions/renew-modal.tsx index 1d9ec595e..3b9fca1db 100644 --- a/app/frontend/src/javascript/components/subscriptions/renew-modal.tsx +++ b/app/frontend/src/javascript/components/subscriptions/renew-modal.tsx @@ -132,7 +132,7 @@ const RenewModal: React.FC = ({ isOpen, toggleModal, subscripti readOnly/>
- + {subscription.plan.monthly_payment && } {price?.schedule && } {price && !price?.schedule &&

{t('app.admin.renew_subscription_modal.pay_in_one_go')}

@@ -145,6 +145,7 @@ const RenewModal: React.FC = ({ isOpen, toggleModal, subscripti afterSuccess={onPaymentSuccess} onError={onError} cart={cart} + updateCart={setCart} currentUser={operator} customer={customer} schedule={price?.schedule as PaymentSchedule} /> diff --git a/app/frontend/src/javascript/directives/cart.js b/app/frontend/src/javascript/directives/cart.js index 85cced6b2..3c08a0cda 100644 --- a/app/frontend/src/javascript/directives/cart.js +++ b/app/frontend/src/javascript/directives/cart.js @@ -378,6 +378,17 @@ Application.Directives.directive('cart', ['$rootScope', '$uibModal', 'dialogs', growl.error(message); }; + /** + * Callback triggered when a child component (LocalPaymentModal) requires to update the cart content + * @param cart {ShoppingCart} + */ + $scope.updateCart = (cart) => { + setTimeout(() => { + $scope.localPayment.cartItems = cart; + $scope.$apply(); + }, 50); + }; + /* PRIVATE SCOPE */ /** diff --git a/app/frontend/templates/shared/_cart.html b/app/frontend/templates/shared/_cart.html index e3df05503..f13bd55a4 100644 --- a/app/frontend/templates/shared/_cart.html +++ b/app/frontend/templates/shared/_cart.html @@ -216,6 +216,7 @@ after-success="afterLocalPaymentSuccess" on-error="onLocalPaymentError" cart="localPayment.cartItems" + update-cart="updateCart" current-user="currentUser" customer="user" schedule="schedule.payment_schedule"/> diff --git a/app/models/invoice.rb b/app/models/invoice.rb index 8ebb037d7..3e0e6a108 100644 --- a/app/models/invoice.rb +++ b/app/models/invoice.rb @@ -130,7 +130,7 @@ class Invoice < PaymentDocument return true if user.nil? # workaround for reservation saved after invoice - if main_item&.object_type == 'Reservation' && main_item&.object&.reservable_type == 'Training' + if main_item.object_type == 'Reservation' && main_item.object&.reservable_type == 'Training' user.trainings.include?(main_item.object.reservable_id) else false diff --git a/app/models/payment_document.rb b/app/models/payment_document.rb index 39149a5c7..1f2ef946c 100644 --- a/app/models/payment_document.rb +++ b/app/models/payment_document.rb @@ -22,7 +22,7 @@ class PaymentDocument < Footprintable self.wallet_transaction_id = transaction_id end - def post_save(arg); end + def post_save(*args); end def render_resource; end end diff --git a/app/models/payment_schedule.rb b/app/models/payment_schedule.rb index ed13803a7..d6b5f1dbd 100644 --- a/app/models/payment_schedule.rb +++ b/app/models/payment_schedule.rb @@ -75,10 +75,10 @@ class PaymentSchedule < PaymentDocument payment_schedule_items end - def post_save(gateway_method_id) + def post_save(*args) return unless payment_method == 'card' - PaymentGatewayService.new.create_subscription(self, gateway_method_id) + PaymentGatewayService.new.create_subscription(self, *args) end def post_save_extend(gateway_method_id) diff --git a/app/models/shopping_cart.rb b/app/models/shopping_cart.rb index 4e39bc2c5..1758c11f5 100644 --- a/app/models/shopping_cart.rb +++ b/app/models/shopping_cart.rb @@ -61,7 +61,7 @@ class ShoppingCart payment = create_payment_document(price, objects, payment_id, payment_type) WalletService.debit_user_wallet(payment, @customer) payment.save - payment.post_save(payment_id) + payment.post_save(payment_id, payment_type) end success = !payment.nil? && objects.map(&:errors).flatten.map(&:empty?).all? && items.map(&:errors).map(&:empty?).all? diff --git a/app/services/cart_service.rb b/app/services/cart_service.rb index 5ff962ab0..1c4bb29a1 100644 --- a/app/services/cart_service.rb +++ b/app/services/cart_service.rb @@ -17,7 +17,7 @@ class CartService items = [] cart_items[:items].each do |item| if ['subscription', :subscription].include?(item.keys.first) - items.push(CartItem::Subscription.new(plan_info[:plan], @customer)) if plan_info[:new_subscription] + items.push(CartItem::Subscription.new(plan_info[:plan], @customer, item[:subscription][:start_at])) if plan_info[:new_subscription] elsif ['reservation', :reservation].include?(item.keys.first) items.push(reservable_from_hash(item[:reservation], plan_info)) elsif ['prepaid_pack', :prepaid_pack].include?(item.keys.first) diff --git a/app/services/payment_gateway_service.rb b/app/services/payment_gateway_service.rb index efdd7f9f1..5c6f855f6 100644 --- a/app/services/payment_gateway_service.rb +++ b/app/services/payment_gateway_service.rb @@ -19,8 +19,8 @@ class PaymentGatewayService @gateway = service.new end - def create_subscription(payment_schedule, gateway_object_id) - @gateway.create_subscription(payment_schedule, gateway_object_id) + def create_subscription(payment_schedule, *args) + @gateway.create_subscription(payment_schedule, *args) end def extend_subscription(payment_schedule, gateway_object_id) diff --git a/app/views/api/members/_member.json.jbuilder b/app/views/api/members/_member.json.jbuilder index ce47ba100..bda15311c 100644 --- a/app/views/api/members/_member.json.jbuilder +++ b/app/views/api/members/_member.json.jbuilder @@ -72,6 +72,7 @@ if member.subscription json.interval member.subscription.plan.interval json.interval_count member.subscription.plan.interval_count json.amount member.subscription.plan.amount ? (member.subscription.plan.amount / 100.0) : 0 + json.monthly_payment member.subscription.plan.monthly_payment end end end diff --git a/lib/payment/service.rb b/lib/payment/service.rb index 4437328e8..0d050becb 100644 --- a/lib/payment/service.rb +++ b/lib/payment/service.rb @@ -6,7 +6,7 @@ module Payment; end # Abstract class that must be implemented by each payment gateway. # Provides methods to create remote objects on the payment gateway class Payment::Service - def create_subscription(_payment_schedule, _gateway_object_id); end + def create_subscription(_payment_schedule, *args); end def create_user(_user_id); end diff --git a/lib/stripe/service.rb b/lib/stripe/service.rb index 249184509..4e1b00480 100644 --- a/lib/stripe/service.rb +++ b/lib/stripe/service.rb @@ -14,7 +14,7 @@ class Stripe::Service < Payment::Service payment_schedule = price_details[:schedule][:payment_schedule] payment_schedule.payment_schedule_items = price_details[:schedule][:items] first_item = price_details[:schedule][:items].min_by(&:due_date) - subscription = shopping_cart.items.find { |item| item.class == CartItem::Subscription } + subscription = shopping_cart.items.find { |item| item.class == CartItem::Subscription }.to_object reservable_stp_id = shopping_cart.items.find { |item| item.is_a?(CartItem::Reservation) }&.to_object &.reservable&.payment_gateway_object&.gateway_object_id @@ -29,33 +29,46 @@ class Stripe::Service < Payment::Service stripe_key = Setting.get('stripe_secret_key') - stp_subscription = Stripe::Subscription.create({ - customer: shopping_cart.customer.payment_gateway_object.gateway_object_id, - cancel_at: (payment_schedule.payment_schedule_items.max_by(&:due_date).due_date + 1.month).to_i, - add_invoice_items: items, - coupon: payment_schedule.coupon&.code, - items: [ - { price: price[:id] } - ], - default_payment_method: payment_method_id, - expand: %w[latest_invoice.payment_intent] - }, { api_key: stripe_key }) - return stp_subscription unless subscription.start_at - - Stripe::SubscriptionSchedule.create({ - from_subscription: stp_subscription.id, - start_date: subscription.start_at - }, { api_key: stripe_key }) - - stp_subscription + if subscription.start_at.nil? + Stripe::Subscription.create({ + customer: shopping_cart.customer.payment_gateway_object.gateway_object_id, + cancel_at: (payment_schedule.payment_schedule_items.max_by(&:due_date).due_date + 1.month).to_i, + add_invoice_items: items, + coupon: payment_schedule.coupon&.code, + items: [ + { price: price[:id] } + ], + default_payment_method: payment_method_id, + expand: %w[latest_invoice.payment_intent] + }, { api_key: stripe_key }) + else + Stripe::SubscriptionSchedule.create({ + customer: shopping_cart.customer.payment_gateway_object.gateway_object_id, + start_date: subscription.start_at.nil? ? 'now' : subscription.start_at.to_i, + end_behavior: 'cancel', + phases: [ + { + items: [ + { price: price[:id] } + ], + add_invoice_items: items, + coupon: payment_schedule.coupon&.code, + default_payment_method: payment_method_id, + end_date: ( + payment_schedule.payment_schedule_items.max_by(&:due_date).due_date + 1.month + ).to_i + } + ] + }, { api_key: stripe_key }) + end end - def create_subscription(payment_schedule, stp_subscription_id) + def create_subscription(payment_schedule, stp_object_id, stp_object_type) stripe_key = Setting.get('stripe_secret_key') - stp_subscription = Stripe::Subscription.retrieve({ id: stp_subscription_id }, { api_key: stripe_key }) + stp_subscription = Stripe::Item.new(stp_object_type, stp_object_id).retrieve - payment_method_id = stp_subscription.default_payment_method + payment_method_id = Stripe::Customer.retrieve(stp_subscription.customer, api_key: stripe_key).invoice_settings.default_payment_method payment_method = Stripe::PaymentMethod.retrieve(payment_method_id, api_key: stripe_key) pgo = PaymentGatewayObject.new(item: payment_schedule) pgo.gateway_object = payment_method