From b0ef9e097de2c3f4b2ceba92c2070653a2875660 Mon Sep 17 00:00:00 2001 From: Sylvain Date: Tue, 9 Feb 2021 12:09:26 +0100 Subject: [PATCH] WIP: stripe update card --- CHANGELOG.md | 2 +- Gemfile | 2 +- Gemfile.lock | 4 +- .../api/payment_schedules_controller.rb | 16 ++- app/controllers/api/payments_controller.rb | 11 ++ .../src/javascript/api/payment-schedule.ts | 11 +- app/frontend/src/javascript/api/payment.ts | 11 +- .../src/javascript/components/fab-button.tsx | 8 +- .../components/payment-schedules-list.tsx | 15 ++- .../components/payment-schedules-table.tsx | 110 +++++++++++++++--- .../components/stripe-card-update.tsx | 101 ++++++++++++++++ .../src/javascript/models/payment-schedule.ts | 12 ++ app/frontend/src/javascript/models/payment.ts | 5 + .../modules/payment-schedules-table.scss | 57 +++++++++ .../templates/admin/invoices/index.html | 2 +- app/policies/payment_schedule_policy.rb | 20 ++-- .../_payment_schedule.json.jbuilder | 1 + config/locales/app.admin.en.yml | 1 + config/locales/app.admin.fr.yml | 1 + config/routes.rb | 2 + 20 files changed, 348 insertions(+), 44 deletions(-) create mode 100644 app/frontend/src/javascript/components/stripe-card-update.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 30a0c9f95..ef5787f28 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## Next release - Refactored theme builder to use scss files -- Updated stripe gem to 5.21.0 +- Updated stripe gem to 5.29.0 - Architecture documentation - Improved coupon creation/deletion workflow - Default texts for the login modal diff --git a/Gemfile b/Gemfile index 7a07ab9e9..64688ebed 100644 --- a/Gemfile +++ b/Gemfile @@ -90,7 +90,7 @@ gem 'sidekiq', '>= 6.0.7' gem 'sidekiq-cron' gem 'sidekiq-unique-jobs', '~> 6.0.22' -gem 'stripe', '5.21.0' +gem 'stripe', '5.29.0' gem 'recurrence' diff --git a/Gemfile.lock b/Gemfile.lock index bd1bfa590..67054a962 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -373,7 +373,7 @@ GEM actionpack (>= 4.0) activesupport (>= 4.0) sprockets (>= 3.0.0) - stripe (5.21.0) + stripe (5.29.0) sync (0.5.0) sys-filesystem (1.3.3) ffi @@ -488,7 +488,7 @@ DEPENDENCIES sidekiq-unique-jobs (~> 6.0.22) spring spring-watcher-listen (~> 2.0.0) - stripe (= 5.21.0) + stripe (= 5.29.0) sys-filesystem tzinfo-data vcr (= 3.0.1) diff --git a/app/controllers/api/payment_schedules_controller.rb b/app/controllers/api/payment_schedules_controller.rb index 4b70bd765..0a2baa7cd 100644 --- a/app/controllers/api/payment_schedules_controller.rb +++ b/app/controllers/api/payment_schedules_controller.rb @@ -4,7 +4,7 @@ class API::PaymentSchedulesController < API::ApiController before_action :authenticate_user! before_action :set_payment_schedule, only: %i[download] - before_action :set_payment_schedule_item, only: %i[cash_check refresh_item] + before_action :set_payment_schedule_item, only: %i[cash_check refresh_item pay_item] def list authorize PaymentSchedule @@ -42,6 +42,20 @@ class API::PaymentSchedulesController < API::ApiController render json: { state: 'refreshed' }, status: :ok end + def pay_item + authorize @payment_schedule_item.payment_schedule + + stripe_key = Setting.get('stripe_secret_key') + stp_invoice = Stripe::Invoice.pay(@payment_schedule_item.stp_invoice_id, {}, { api_key: stripe_key }) + + render json: { status: stp_invoice.status }, status: :ok + rescue Stripe::StripeError => e + stripe_key = Setting.get('stripe_secret_key') + stp_invoice = Stripe::Invoice.retrieve(@payment_schedule_item.stp_invoice_id, api_key: stripe_key) + + render json: { status: stp_invoice.status, error: e }, status: :unprocessable_entity + end + private def set_payment_schedule diff --git a/app/controllers/api/payments_controller.rb b/app/controllers/api/payments_controller.rb index 2d9b70a8b..350377e91 100644 --- a/app/controllers/api/payments_controller.rb +++ b/app/controllers/api/payments_controller.rb @@ -93,6 +93,17 @@ class API::PaymentsController < API::ApiController render json: e, status: :unprocessable_entity end + def update_card + user = User.find(params[:user_id]) + key = Setting.get('stripe_secret_key') + Stripe::Customer.update(user.stp_customer_id, + { invoice_settings: { default_payment_method: params[:payment_method_id] } }, + { api_key: key }) + render json: { updated: true }, status: :ok + rescue Stripe::StripeError => e + render json: { updated: false, error: e }, status: :unprocessable_entity + end + private def on_reservation_success(intent, details) diff --git a/app/frontend/src/javascript/api/payment-schedule.ts b/app/frontend/src/javascript/api/payment-schedule.ts index f87e8f41e..25edf41ae 100644 --- a/app/frontend/src/javascript/api/payment-schedule.ts +++ b/app/frontend/src/javascript/api/payment-schedule.ts @@ -1,9 +1,9 @@ import apiClient from './api-client'; import { AxiosResponse } from 'axios'; import { - CashCheckResponse, + CashCheckResponse, PayItemResponse, PaymentSchedule, - PaymentScheduleIndexRequest, + PaymentScheduleIndexRequest, RefreshItemResponse } from '../models/payment-schedule'; import wrapPromise, { IWrapPromise } from '../lib/wrap-promise'; @@ -18,11 +18,16 @@ export default class PaymentScheduleAPI { return res?.data; } - async refreshItem(paymentScheduleItemId: number): Promise { + async refreshItem(paymentScheduleItemId: number): Promise { const res: AxiosResponse = await apiClient.post(`/api/payment_schedules/items/${paymentScheduleItemId}/refresh_item`); return res?.data; } + async payItem(paymentScheduleItemId: number): Promise { + const res: AxiosResponse = await apiClient.post(`/api/payment_schedules/items/${paymentScheduleItemId}/pay_item`); + return res?.data; + } + static list (query: PaymentScheduleIndexRequest): IWrapPromise> { const api = new PaymentScheduleAPI(); return wrapPromise(api.list(query)); diff --git a/app/frontend/src/javascript/api/payment.ts b/app/frontend/src/javascript/api/payment.ts index b82472960..eff57cfd7 100644 --- a/app/frontend/src/javascript/api/payment.ts +++ b/app/frontend/src/javascript/api/payment.ts @@ -1,6 +1,6 @@ import apiClient from './api-client'; import { AxiosResponse } from 'axios'; -import { CartItems, IntentConfirmation, PaymentConfirmation } from '../models/payment'; +import { CartItems, IntentConfirmation, PaymentConfirmation, UpdateCardResponse } from '../models/payment'; export default class PaymentAPI { static async confirm (stp_payment_method_id: string, cart_items: CartItems): Promise { @@ -16,6 +16,7 @@ export default class PaymentAPI { return res?.data; } + // TODO, type the response static async confirmPaymentSchedule (setup_intent_id: string, cart_items: CartItems): Promise { const res: AxiosResponse = await apiClient.post(`/api/payments/confirm_payment_schedule`, { setup_intent_id, @@ -23,5 +24,13 @@ export default class PaymentAPI { }); return res?.data; } + + static async updateCard (user_id: number, stp_payment_method_id: string): Promise { + const res: AxiosResponse = await apiClient.post(`/api/payments/update_card`, { + user_id, + payment_method_id: stp_payment_method_id, + }); + return res?.data; + } } diff --git a/app/frontend/src/javascript/components/fab-button.tsx b/app/frontend/src/javascript/components/fab-button.tsx index d87ec74fe..e08689fa9 100644 --- a/app/frontend/src/javascript/components/fab-button.tsx +++ b/app/frontend/src/javascript/components/fab-button.tsx @@ -9,10 +9,12 @@ interface FabButtonProps { icon?: ReactNode, className?: string, disabled?: boolean, + type?: 'submit' | 'reset' | 'button', + form?: string, } -export const FabButton: React.FC = ({ onClick, icon, className, disabled, children }) => { +export const FabButton: React.FC = ({ onClick, icon, className, disabled, type, form, children }) => { /** * Check if the current component was provided an icon to display */ @@ -30,10 +32,12 @@ export const FabButton: React.FC = ({ onClick, icon, className, } return ( - ); } +FabButton.defaultProps = { type: 'button' }; + diff --git a/app/frontend/src/javascript/components/payment-schedules-list.tsx b/app/frontend/src/javascript/components/payment-schedules-list.tsx index 92fa5e7f8..b2868a19c 100644 --- a/app/frontend/src/javascript/components/payment-schedules-list.tsx +++ b/app/frontend/src/javascript/components/payment-schedules-list.tsx @@ -11,13 +11,18 @@ import PaymentScheduleAPI from '../api/payment-schedule'; import { DocumentFilters } from './document-filters'; import { PaymentSchedulesTable } from './payment-schedules-table'; import { FabButton } from './fab-button'; +import { User } from '../models/user'; declare var Application: IApplication; +interface PaymentSchedulesListProps { + currentUser: User +} + const PAGE_SIZE = 20; const paymentSchedulesList = PaymentScheduleAPI.list({ query: { page: 1, size: 20 } }); -const PaymentSchedulesList: React.FC = () => { +const PaymentSchedulesList: React.FC = ({ currentUser }) => { const { t } = useTranslation('admin'); const [paymentSchedules, setPaymentSchedules] = useState(paymentSchedulesList.read()); @@ -88,7 +93,7 @@ const PaymentSchedulesList: React.FC = () => { {!hasSchedules() &&
{t('app.admin.invoices.payment_schedules.no_payment_schedules')}
} {hasSchedules() &&
- + {hasMoreSchedules() && {t('app.admin.invoices.payment_schedules.load_more')}}
} @@ -96,12 +101,12 @@ const PaymentSchedulesList: React.FC = () => { } -const PaymentSchedulesListWrapper: React.FC = () => { +const PaymentSchedulesListWrapper: React.FC = ({ currentUser }) => { return ( - + ); } -Application.Components.component('paymentSchedulesList', react2angular(PaymentSchedulesListWrapper)); +Application.Components.component('paymentSchedulesList', react2angular(PaymentSchedulesListWrapper, ['currentUser'])); diff --git a/app/frontend/src/javascript/components/payment-schedules-table.tsx b/app/frontend/src/javascript/components/payment-schedules-table.tsx index 87bd28fbb..c4df1983e 100644 --- a/app/frontend/src/javascript/components/payment-schedules-table.tsx +++ b/app/frontend/src/javascript/components/payment-schedules-table.tsx @@ -14,23 +14,33 @@ import { FabModal } from './fab-modal'; import PaymentScheduleAPI from '../api/payment-schedule'; import { StripeElements } from './stripe-elements'; import { StripeConfirm } from './stripe-confirm'; +import stripeLogo from '../../../images/powered_by_stripe.png'; +import mastercardLogo from '../../../images/mastercard.png'; +import visaLogo from '../../../images/visa.png'; +import { StripeCardUpdate } from './stripe-card-update'; +import { User } from '../models/user'; declare var Fablab: IFablab; interface PaymentSchedulesTableProps { paymentSchedules: Array, showCustomer?: boolean, - refreshList: () => void + refreshList: () => void, + operator: User, } -const PaymentSchedulesTableComponent: React.FC = ({ paymentSchedules, showCustomer, refreshList }) => { +const PaymentSchedulesTableComponent: React.FC = ({ paymentSchedules, showCustomer, refreshList, operator }) => { const { t } = useTranslation('admin'); const [showExpanded, setShowExpanded] = useState>(new Map()); const [showConfirmCashing, setShowConfirmCashing] = useState(false); const [showResolveAction, setShowResolveAction] = useState(false); const [isConfirmActionDisabled, setConfirmActionDisabled] = useState(true); + const [showUpdateCard, setShowUpdateCard] = useState(false); const [tempDeadline, setTempDeadline] = useState(null); + const [tempSchedule, setTempSchedule] = useState(null); + const [canSubmitUpdateCard, setCanSubmitUpdateCard] = useState(true); + const [errors, setErrors] = useState(null); /** * Check if the requested payment schedule is displayed with its deadlines (PaymentScheduleItem) or without them @@ -123,7 +133,7 @@ const PaymentSchedulesTableComponent: React.FC = ({ /** * Return the action button(s) for the given deadline */ - const itemButtons = (item: PaymentScheduleItem): JSX.Element => { + const itemButtons = (item: PaymentScheduleItem, schedule: PaymentSchedule): JSX.Element => { switch (item.state) { case PaymentScheduleItemState.Paid: return downloadButton(TargetType.Invoice, item.invoice_id); @@ -143,7 +153,7 @@ const PaymentSchedulesTableComponent: React.FC = ({ ); case PaymentScheduleItemState.RequirePaymentMethod: return ( - }> {t('app.admin.invoices.schedules_table.update_card')} @@ -222,18 +232,63 @@ const PaymentSchedulesTableComponent: React.FC = ({ /** * Callback triggered when the user's clicks on the "update card" button: show a modal to input a new card */ - const handleUpdateCard = (item: PaymentScheduleItem): ReactEventHandler => { + const handleUpdateCard = (item: PaymentScheduleItem, paymentSchedule: PaymentSchedule): ReactEventHandler => { return (): void => { - /* - TODO - - Notify the customer, collect new payment information, and create a new payment method - - Attach the payment method to the customer - - Update the default payment method - - Pay the invoice using the new payment method - */ + setTempDeadline(item); + setTempSchedule(paymentSchedule); + toggleUpdateCardModal(); } } + /** + * Show/hide the modal dialog to update the bank card details + */ + const toggleUpdateCardModal = (): void => { + setShowUpdateCard(!showUpdateCard); + } + + /** + * Return the logos, shown in the modal footer. + */ + const logoFooter = (): ReactNode => { + return ( +
+ + powered by stripe + mastercard + visa +
+ ); + } + + /** + * When the submit button is pushed, disable it to prevent double form submission + */ + const handleCardUpdateSubmit = (): void => { + setCanSubmitUpdateCard(false); + } + + /** + * When the card was successfully updated, pay the invoice (using the new payment method) and close the modal + */ + const handleCardUpdateSuccess = (): void => { + const api = new PaymentScheduleAPI(); + api.payItem(tempDeadline.id).then(() => { + refreshList(); + toggleUpdateCardModal(); + }).catch((err) => { + handleCardUpdateError(err.error); + }); + } + + /** + * When the card was not updated, show the error + */ + const handleCardUpdateError = (error): void => { + setErrors(error); + setCanSubmitUpdateCard(true); + } + return (
@@ -278,7 +333,7 @@ const PaymentSchedulesTableComponent: React.FC = ({ - + )}
{formatDate(item.due_date)} {formatPrice(item.amount)} {formatState(item)}{itemButtons(item)}{itemButtons(item, p)}
@@ -314,6 +369,31 @@ const PaymentSchedulesTableComponent: React.FC = ({ preventConfirm={isConfirmActionDisabled}> {tempDeadline && } + + {tempDeadline && tempSchedule && + {errors &&
+ {errors} +
} +
} +
+ {canSubmitUpdateCard && } + {!canSubmitUpdateCard &&
+
+ +
+
} +
+
@@ -322,10 +402,10 @@ const PaymentSchedulesTableComponent: React.FC = ({ PaymentSchedulesTableComponent.defaultProps = { showCustomer: false }; -export const PaymentSchedulesTable: React.FC = ({ paymentSchedules, showCustomer, refreshList }) => { +export const PaymentSchedulesTable: React.FC = ({ paymentSchedules, showCustomer, refreshList, operator }) => { return ( - + ); } diff --git a/app/frontend/src/javascript/components/stripe-card-update.tsx b/app/frontend/src/javascript/components/stripe-card-update.tsx new file mode 100644 index 000000000..3bbf618f4 --- /dev/null +++ b/app/frontend/src/javascript/components/stripe-card-update.tsx @@ -0,0 +1,101 @@ +import React, { FormEvent } from 'react'; +import { CardElement, useElements, useStripe } from '@stripe/react-stripe-js'; +import { SetupIntent } from "@stripe/stripe-js"; +import PaymentAPI from '../api/payment'; +import { PaymentConfirmation } from '../models/payment'; +import { User } from '../models/user'; + +interface StripeCardUpdateProps { + onSubmit: () => void, + onSuccess: (result: SetupIntent|PaymentConfirmation|any) => void, + onError: (message: string) => void, + customerId: number, + operator: User, + className?: string, +} + +/** + * A simple form component to collect and update the credit card details, for Stripe. + * + * The form validation button must be created elsewhere, using the attribute form="stripe-card". + */ +export const StripeCardUpdate: React.FC = ({ onSubmit, onSuccess, onError, className, customerId, operator, children }) => { + + const stripe = useStripe(); + const elements = useElements(); + + /** + * Handle the submission of the form. Depending on the configuration, it will create the payment method on Stripe, + * or it will process a payment with the inputted card. + */ + const handleSubmit = async (event: FormEvent): Promise => { + event.preventDefault(); + onSubmit(); + + // Stripe.js has not loaded yet + if (!stripe || !elements) { return; } + + const cardElement = elements.getElement(CardElement); + const { error, paymentMethod } = await stripe.createPaymentMethod({ + type: 'card', + card: cardElement, + }); + + if (error) { + // stripe error + onError(error.message); + } else { + try { + // we start by associating the payment method with the user + const { client_secret } = await PaymentAPI.setupIntent(customerId); + const { error } = await stripe.confirmCardSetup(client_secret, { + payment_method: paymentMethod.id, + mandate_data: { + customer_acceptance: { + type: 'online', + online: { + ip_address: operator.ip_address, + user_agent: navigator.userAgent + } + } + } + }) + if (error) { + onError(error.message); + } else { + // then we update the default payment method + const res = await PaymentAPI.updateCard(customerId, paymentMethod.id); + onSuccess(res); + } + } catch (err) { + // catch api errors + onError(err); + } + } + } + + /** + * Options for the Stripe's card input + */ + const cardOptions = { + style: { + base: { + fontSize: '16px', + color: '#424770', + '::placeholder': { color: '#aab7c4' } + }, + invalid: { + color: '#9e2146', + iconColor: '#9e2146' + }, + }, + hidePostalCode: true + }; + + return ( +
+ + {children} + + ); +} diff --git a/app/frontend/src/javascript/models/payment-schedule.ts b/app/frontend/src/javascript/models/payment-schedule.ts index ce8c6d6e8..374587c08 100644 --- a/app/frontend/src/javascript/models/payment-schedule.ts +++ b/app/frontend/src/javascript/models/payment-schedule.ts @@ -1,3 +1,5 @@ +import { StripeIbanElement } from '@stripe/stripe-js'; + export enum PaymentScheduleItemState { New = 'new', Pending = 'pending', @@ -42,6 +44,7 @@ export interface PaymentSchedule { created_at: Date, chained_footprint: boolean, user: { + id: number, name: string }, operator: { @@ -65,3 +68,12 @@ export interface CashCheckResponse { state: PaymentScheduleItemState, payment_method: PaymentMethod } + +export interface RefreshItemResponse { + state: 'refreshed' +} + +export interface PayItemResponse { + status: 'draft' | 'open' | 'paid' | 'uncollectible' | 'void', + error?: string +} diff --git a/app/frontend/src/javascript/models/payment.ts b/app/frontend/src/javascript/models/payment.ts index ce9fc90a8..32b641ccd 100644 --- a/app/frontend/src/javascript/models/payment.ts +++ b/app/frontend/src/javascript/models/payment.ts @@ -24,3 +24,8 @@ export interface CartItems { subscription?: SubscriptionRequest, coupon_code?: string } + +export interface UpdateCardResponse { + updated: boolean, + error?: string +} diff --git a/app/frontend/src/stylesheets/modules/payment-schedules-table.scss b/app/frontend/src/stylesheets/modules/payment-schedules-table.scss index 86121e97a..1b4ab3130 100644 --- a/app/frontend/src/stylesheets/modules/payment-schedules-table.scss +++ b/app/frontend/src/stylesheets/modules/payment-schedules-table.scss @@ -121,3 +121,60 @@ color: black; } } + +.fab-modal.update-card-modal { + .fab-modal-content { + .card-form { + background-color: #f4f3f3; + border: 1px solid #ddd; + border-radius: 6px 6px 0 0; + box-shadow: 0 1px 1px rgba(0, 0, 0, 0.05); + padding: 15px; + + .stripe-errors { + padding: 4px 0; + color: #9e2146; + } + } + .submit-card { + .submit-card-btn { + width: 100%; + border: 1px solid #ddd; + border-radius: 0 0 6px 6px; + border-top: 0; + padding: 16px; + color: #fff; + background-color: #1d98ec; + margin-bottom: 15px; + + &[disabled] { + background-color: lighten(#1d98ec, 20%); + } + } + + + .payment-pending { + @extend .submit-card-btn; + @extend .submit-card-btn[disabled]; + text-align: center; + padding: 4px; + } + } + } + .fab-modal-footer { + .stripe-modal-icons { + & { + text-align: center; + } + + .fa.fa-lock { + top: 7px; + color: #9edd78; + } + + img { + margin-right: 10px; + } + } + } +} diff --git a/app/frontend/templates/admin/invoices/index.html b/app/frontend/templates/admin/invoices/index.html index ae260c92d..8aa1837b6 100644 --- a/app/frontend/templates/admin/invoices/index.html +++ b/app/frontend/templates/admin/invoices/index.html @@ -35,7 +35,7 @@ - + diff --git a/app/policies/payment_schedule_policy.rb b/app/policies/payment_schedule_policy.rb index eb5fc9916..0a058e702 100644 --- a/app/policies/payment_schedule_policy.rb +++ b/app/policies/payment_schedule_policy.rb @@ -2,19 +2,15 @@ # Check the access policies for API::PaymentSchedulesController class PaymentSchedulePolicy < ApplicationPolicy - def list? - user.admin? || user.manager? + %w[list? cash_check?].each do |action| + define_method action do + user.admin? || user.manager? + end end - def cash_check? - user.admin? || user.manager? - end - - def refresh_item? - user.admin? || user.manager? || (record.invoicing_profile.user_id == user.id) - end - - def download? - user.admin? || user.manager? || (record.invoicing_profile.user_id == user.id) + %w[refresh_item? download? pay_item?].each do |action| + define_method action do + user.admin? || user.manager? || (record.invoicing_profile.user_id == user.id) + end end end diff --git a/app/views/api/payment_schedules/_payment_schedule.json.jbuilder b/app/views/api/payment_schedules/_payment_schedule.json.jbuilder index 497df01e4..465300fad 100644 --- a/app/views/api/payment_schedules/_payment_schedule.json.jbuilder +++ b/app/views/api/payment_schedules/_payment_schedule.json.jbuilder @@ -4,6 +4,7 @@ json.extract! payment_schedule, :id, :reference, :created_at, :payment_method json.total payment_schedule.total / 100.00 json.chained_footprint payment_schedule.check_footprint json.user do + json.id payment_schedule.invoicing_profile&.user&.id json.name payment_schedule.invoicing_profile.full_name end if payment_schedule.operator_profile diff --git a/config/locales/app.admin.en.yml b/config/locales/app.admin.en.yml index ac2399f6e..182e182f1 100644 --- a/config/locales/app.admin.en.yml +++ b/config/locales/app.admin.en.yml @@ -669,6 +669,7 @@ en: confirm_button: "Confirm" resolve_action: "Resolve the action" ok_button: "OK" + validate_button: "Validate the new card" document_filters: reference: "Reference" customer: "Customer" diff --git a/config/locales/app.admin.fr.yml b/config/locales/app.admin.fr.yml index 5cc962c73..4e49ccff7 100644 --- a/config/locales/app.admin.fr.yml +++ b/config/locales/app.admin.fr.yml @@ -669,6 +669,7 @@ fr: confirm_button: "Confirmer" resolve_action: "Résoudre l'action" ok_button: "OK" + validate_button: "Valider la nouvelle carte" document_filters: reference: "Référence" customer: "Client" diff --git a/config/routes.rb b/config/routes.rb index 9929d27fe..b81bf6c4b 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -116,6 +116,7 @@ Rails.application.routes.draw do get 'download', on: :member post 'items/:id/cash_check', action: 'cash_check', on: :collection post 'items/:id/refresh_item', action: 'refresh_item', on: :collection + post 'items/:id/pay_item', action: 'pay_item', on: :collection end resources :i_calendar, only: %i[index create destroy] do @@ -174,6 +175,7 @@ Rails.application.routes.draw do get 'payments/online_payment_status' => 'payments/online_payment_status' get 'payments/setup_intent/:user_id' => 'payments#setup_intent' post 'payments/confirm_payment_schedule' => 'payments#confirm_payment_schedule' + post 'payments/update_card' => 'payments#update_card' # FabAnalytics get 'analytics/data' => 'analytics#data'