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..fceb0da50 100644 --- a/app/controllers/api/payment_schedules_controller.rb +++ b/app/controllers/api/payment_schedules_controller.rb @@ -3,8 +3,8 @@ # API Controller for resources of PaymentSchedule 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, only: %i[download cancel] + before_action :set_payment_schedule_item, only: %i[cash_check refresh_item pay_item] def list authorize PaymentSchedule @@ -37,11 +37,34 @@ class API::PaymentSchedulesController < API::ApiController def refresh_item authorize @payment_schedule_item.payment_schedule - PaymentScheduleItemWorker.new.perform(params[:id]) + PaymentScheduleItemWorker.new.perform(@payment_schedule_item.id) 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 }) + PaymentScheduleItemWorker.new.perform(@payment_schedule_item.id) + + 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) + PaymentScheduleItemWorker.new.perform(@payment_schedule_item.id) + + render json: { status: stp_invoice.status, error: e }, status: :unprocessable_entity + end + + def cancel + authorize @payment_schedule + + canceled_at = PaymentScheduleService.cancel(@payment_schedule) + render json: { canceled_at: canceled_at }, status: :ok + 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..33a0acc2a 100644 --- a/app/frontend/src/javascript/api/payment-schedule.ts +++ b/app/frontend/src/javascript/api/payment-schedule.ts @@ -1,9 +1,10 @@ import apiClient from './api-client'; import { AxiosResponse } from 'axios'; import { - CashCheckResponse, + CancelScheduleResponse, + CashCheckResponse, PayItemResponse, PaymentSchedule, - PaymentScheduleIndexRequest, + PaymentScheduleIndexRequest, RefreshItemResponse } from '../models/payment-schedule'; import wrapPromise, { IWrapPromise } from '../lib/wrap-promise'; @@ -18,11 +19,21 @@ 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; + } + + async cancel (paymentScheduleId: number): Promise { + const res: AxiosResponse = await apiClient.put(`/api/payment_schedules/${paymentScheduleId}/cancel`); + 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..ae8a9587c 100644 --- a/app/frontend/src/javascript/components/payment-schedules-table.tsx +++ b/app/frontend/src/javascript/components/payment-schedules-table.tsx @@ -14,23 +14,34 @@ 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); + const [showCancelSubscription, setShowCancelSubscription] = useState(false); /** * Check if the requested payment schedule is displayed with its deadlines (PaymentScheduleItem) or without them @@ -123,7 +134,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,11 +154,18 @@ const PaymentSchedulesTableComponent: React.FC = ({ ); case PaymentScheduleItemState.RequirePaymentMethod: return ( - }> {t('app.admin.invoices.schedules_table.update_card')} ); + case PaymentScheduleItemState.Error: + return ( + }> + {t('app.admin.invoices.schedules_table.cancel_subscription')} + + ) default: return } @@ -222,18 +240,91 @@ 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); + } + + /** + * Callback triggered when the user clicks on the "cancel subscription" button + */ + const handleCancelSubscription = (schedule: PaymentSchedule): ReactEventHandler => { + return (): void => { + setTempSchedule(schedule); + toggleCancelSubscriptionModal(); + } + } + + /** + * Show/hide the modal dialog to cancel the current subscription + */ + const toggleCancelSubscriptionModal = (): void => { + setShowCancelSubscription(!showCancelSubscription); + } + + /** + * When the user has confirmed the cancellation, we transfer the request to the API + */ + const onCancelSubscriptionConfirmed = (): void => { + const api = new PaymentScheduleAPI(); + api.cancel(tempSchedule.id).then(() => { + refreshList(); + toggleCancelSubscriptionModal(); + }); + } + return (
@@ -278,7 +369,7 @@ const PaymentSchedulesTableComponent: React.FC = ({ - + )}
{formatDate(item.due_date)} {formatPrice(item.amount)} {formatState(item)}{itemButtons(item)}{itemButtons(item, p)}
@@ -305,6 +396,14 @@ const PaymentSchedulesTableComponent: React.FC = ({ })} } + + {t('app.admin.invoices.schedules_table.confirm_cancel_subscription')} + = ({ preventConfirm={isConfirmActionDisabled}> {tempDeadline && } + + {tempDeadline && tempSchedule && + {errors &&
+ {errors} +
} +
} +
+ {canSubmitUpdateCard && } + {!canSubmitUpdateCard &&
+
+ +
+
} +
+
@@ -322,10 +446,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..77be171e1 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,16 @@ export interface CashCheckResponse { state: PaymentScheduleItemState, payment_method: PaymentMethod } + +export interface RefreshItemResponse { + state: 'refreshed' +} + +export interface PayItemResponse { + status: 'draft' | 'open' | 'paid' | 'uncollectible' | 'void', + error?: string +} + +export interface CancelScheduleResponse { + canceled_at: Date +} 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/models/subscription.rb b/app/models/subscription.rb index dbbc715ca..d9e69313b 100644 --- a/app/models/subscription.rb +++ b/app/models/subscription.rb @@ -38,7 +38,6 @@ 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/pdfs/pdf/invoice.rb b/app/pdfs/pdf/invoice.rb index aa6beccc2..b96e45e53 100644 --- a/app/pdfs/pdf/invoice.rb +++ b/app/pdfs/pdf/invoice.rb @@ -315,7 +315,9 @@ class PDF::Invoice < Prawn::Document # if the invoice was 100% payed with the wallet ... payment_verbose = I18n.t('invoices.settlement_by_wallet') if total.zero? && wallet_amount - payment_verbose += ' ' + I18n.t('invoices.on_DATE_at_TIME', DATE: I18n.l(invoice.created_at.to_date), TIME:I18n.l(invoice.created_at, format: :hour_minute)) + payment_verbose += ' ' + I18n.t('invoices.on_DATE_at_TIME', + DATE: I18n.l(invoice.created_at.to_date), + TIME: I18n.l(invoice.created_at, format: :hour_minute)) if total.positive? || !invoice.wallet_amount payment_verbose += ' ' + I18n.t('invoices.for_an_amount_of_AMOUNT', AMOUNT: number_to_currency(total)) end diff --git a/app/pdfs/pdf/payment_schedule.rb b/app/pdfs/pdf/payment_schedule.rb index 1c4f4bb18..dbe41a566 100644 --- a/app/pdfs/pdf/payment_schedule.rb +++ b/app/pdfs/pdf/payment_schedule.rb @@ -99,7 +99,10 @@ class PDF::PaymentSchedule < Prawn::Document # payment method move_down 20 payment_verbose = _t('payment_schedules.settlement_by_METHOD', METHOD: payment_schedule.payment_method) - payment_verbose = I18n.t('payment_schedules.settlement_by_wallet', AMOUNT: payment_schedule.wallet_amount / 100.00) if payment_schedule.wallet_amount + if payment_schedule.wallet_amount + payment_verbose += I18n.t('payment_schedules.settlement_by_wallet', + AMOUNT: number_to_currency(payment_schedule.wallet_amount / 100.00)) + end text payment_verbose # important information diff --git a/app/policies/payment_schedule_policy.rb b/app/policies/payment_schedule_policy.rb index eb5fc9916..33cf156a8 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? cancel?].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/services/payment_schedule_service.rb b/app/services/payment_schedule_service.rb index 46963059a..1c648c0b5 100644 --- a/app/services/payment_schedule_service.rb +++ b/app/services/payment_schedule_service.rb @@ -138,6 +138,20 @@ class PaymentScheduleService ps end + def self.cancel(payment_schedule) + # cancel all item where state != paid + payment_schedule.ordered_items.each do |item| + next if item.state == 'paid' + + item.update_attributes(state: 'canceled') + end + # cancel subscription + subscription = Subscription.find(payment_schedule.payment_schedule_items.first.details['subscription_id']) + subscription.cancel + + subscription.canceled_at + end + private ## 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..743f5c854 100644 --- a/config/locales/app.admin.en.yml +++ b/config/locales/app.admin.en.yml @@ -659,6 +659,7 @@ en: state_requires_action: "Action required" state_paid: "Paid" state_error: "Error" + state_canceled: "Canceled" method_stripe: "by card" method_check: "by check" confirm_payment: "Confirm payment" @@ -669,6 +670,9 @@ en: confirm_button: "Confirm" resolve_action: "Resolve the action" ok_button: "OK" + validate_button: "Validate the new card" + cancel_subscription: "Cancel the subscription" + confirm_cancel_subscription: "You're about to cancel this payment schedule and the related subscription. Are you sure?" document_filters: reference: "Reference" customer: "Customer" diff --git a/config/locales/app.admin.fr.yml b/config/locales/app.admin.fr.yml index 5cc962c73..543a7692f 100644 --- a/config/locales/app.admin.fr.yml +++ b/config/locales/app.admin.fr.yml @@ -659,6 +659,7 @@ fr: state_requires_action: "Action requise" state_paid: "Payée" state_error: "Erreur" + state_canceled: "Annulée" method_stripe: "par carte" method_check: "par chèque" confirm_payment: "Confirmer l'encaissement" @@ -669,6 +670,9 @@ fr: confirm_button: "Confirmer" resolve_action: "Résoudre l'action" ok_button: "OK" + validate_button: "Valider la nouvelle carte" + cancel_subscription: "Annuler l'abonnement" + confirm_cancel_subscription: "Vous êtes sur le point d'annuler cet échéancier de paiement ainsi que l'abonnement lié. Êtes-vous sur ?" document_filters: reference: "Référence" customer: "Client" diff --git a/config/routes.rb b/config/routes.rb index 9929d27fe..b320ba45d 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -113,9 +113,11 @@ Rails.application.routes.draw do resources :payment_schedules, only: %i[show] do post 'list', action: 'list', on: :collection + put 'cancel', on: :member 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 +176,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'