mirror of
https://github.com/LaCasemate/fab-manager.git
synced 2025-02-21 15:54:22 +01:00
WIP: stripe update card
This commit is contained in:
parent
d1584604b3
commit
b0ef9e097d
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## Next release
|
## Next release
|
||||||
- Refactored theme builder to use scss files
|
- Refactored theme builder to use scss files
|
||||||
- Updated stripe gem to 5.21.0
|
- Updated stripe gem to 5.29.0
|
||||||
- Architecture documentation
|
- Architecture documentation
|
||||||
- Improved coupon creation/deletion workflow
|
- Improved coupon creation/deletion workflow
|
||||||
- Default texts for the login modal
|
- Default texts for the login modal
|
||||||
|
2
Gemfile
2
Gemfile
@ -90,7 +90,7 @@ gem 'sidekiq', '>= 6.0.7'
|
|||||||
gem 'sidekiq-cron'
|
gem 'sidekiq-cron'
|
||||||
gem 'sidekiq-unique-jobs', '~> 6.0.22'
|
gem 'sidekiq-unique-jobs', '~> 6.0.22'
|
||||||
|
|
||||||
gem 'stripe', '5.21.0'
|
gem 'stripe', '5.29.0'
|
||||||
|
|
||||||
gem 'recurrence'
|
gem 'recurrence'
|
||||||
|
|
||||||
|
@ -373,7 +373,7 @@ GEM
|
|||||||
actionpack (>= 4.0)
|
actionpack (>= 4.0)
|
||||||
activesupport (>= 4.0)
|
activesupport (>= 4.0)
|
||||||
sprockets (>= 3.0.0)
|
sprockets (>= 3.0.0)
|
||||||
stripe (5.21.0)
|
stripe (5.29.0)
|
||||||
sync (0.5.0)
|
sync (0.5.0)
|
||||||
sys-filesystem (1.3.3)
|
sys-filesystem (1.3.3)
|
||||||
ffi
|
ffi
|
||||||
@ -488,7 +488,7 @@ DEPENDENCIES
|
|||||||
sidekiq-unique-jobs (~> 6.0.22)
|
sidekiq-unique-jobs (~> 6.0.22)
|
||||||
spring
|
spring
|
||||||
spring-watcher-listen (~> 2.0.0)
|
spring-watcher-listen (~> 2.0.0)
|
||||||
stripe (= 5.21.0)
|
stripe (= 5.29.0)
|
||||||
sys-filesystem
|
sys-filesystem
|
||||||
tzinfo-data
|
tzinfo-data
|
||||||
vcr (= 3.0.1)
|
vcr (= 3.0.1)
|
||||||
|
@ -4,7 +4,7 @@
|
|||||||
class API::PaymentSchedulesController < API::ApiController
|
class API::PaymentSchedulesController < API::ApiController
|
||||||
before_action :authenticate_user!
|
before_action :authenticate_user!
|
||||||
before_action :set_payment_schedule, only: %i[download]
|
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
|
def list
|
||||||
authorize PaymentSchedule
|
authorize PaymentSchedule
|
||||||
@ -42,6 +42,20 @@ class API::PaymentSchedulesController < API::ApiController
|
|||||||
render json: { state: 'refreshed' }, status: :ok
|
render json: { state: 'refreshed' }, status: :ok
|
||||||
end
|
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
|
private
|
||||||
|
|
||||||
def set_payment_schedule
|
def set_payment_schedule
|
||||||
|
@ -93,6 +93,17 @@ class API::PaymentsController < API::ApiController
|
|||||||
render json: e, status: :unprocessable_entity
|
render json: e, status: :unprocessable_entity
|
||||||
end
|
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
|
private
|
||||||
|
|
||||||
def on_reservation_success(intent, details)
|
def on_reservation_success(intent, details)
|
||||||
|
@ -1,9 +1,9 @@
|
|||||||
import apiClient from './api-client';
|
import apiClient from './api-client';
|
||||||
import { AxiosResponse } from 'axios';
|
import { AxiosResponse } from 'axios';
|
||||||
import {
|
import {
|
||||||
CashCheckResponse,
|
CashCheckResponse, PayItemResponse,
|
||||||
PaymentSchedule,
|
PaymentSchedule,
|
||||||
PaymentScheduleIndexRequest,
|
PaymentScheduleIndexRequest, RefreshItemResponse
|
||||||
} from '../models/payment-schedule';
|
} from '../models/payment-schedule';
|
||||||
import wrapPromise, { IWrapPromise } from '../lib/wrap-promise';
|
import wrapPromise, { IWrapPromise } from '../lib/wrap-promise';
|
||||||
|
|
||||||
@ -18,11 +18,16 @@ export default class PaymentScheduleAPI {
|
|||||||
return res?.data;
|
return res?.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
async refreshItem(paymentScheduleItemId: number): Promise<void> {
|
async refreshItem(paymentScheduleItemId: number): Promise<RefreshItemResponse> {
|
||||||
const res: AxiosResponse = await apiClient.post(`/api/payment_schedules/items/${paymentScheduleItemId}/refresh_item`);
|
const res: AxiosResponse = await apiClient.post(`/api/payment_schedules/items/${paymentScheduleItemId}/refresh_item`);
|
||||||
return res?.data;
|
return res?.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async payItem(paymentScheduleItemId: number): Promise<PayItemResponse> {
|
||||||
|
const res: AxiosResponse = await apiClient.post(`/api/payment_schedules/items/${paymentScheduleItemId}/pay_item`);
|
||||||
|
return res?.data;
|
||||||
|
}
|
||||||
|
|
||||||
static list (query: PaymentScheduleIndexRequest): IWrapPromise<Array<PaymentSchedule>> {
|
static list (query: PaymentScheduleIndexRequest): IWrapPromise<Array<PaymentSchedule>> {
|
||||||
const api = new PaymentScheduleAPI();
|
const api = new PaymentScheduleAPI();
|
||||||
return wrapPromise(api.list(query));
|
return wrapPromise(api.list(query));
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import apiClient from './api-client';
|
import apiClient from './api-client';
|
||||||
import { AxiosResponse } from 'axios';
|
import { AxiosResponse } from 'axios';
|
||||||
import { CartItems, IntentConfirmation, PaymentConfirmation } from '../models/payment';
|
import { CartItems, IntentConfirmation, PaymentConfirmation, UpdateCardResponse } from '../models/payment';
|
||||||
|
|
||||||
export default class PaymentAPI {
|
export default class PaymentAPI {
|
||||||
static async confirm (stp_payment_method_id: string, cart_items: CartItems): Promise<PaymentConfirmation> {
|
static async confirm (stp_payment_method_id: string, cart_items: CartItems): Promise<PaymentConfirmation> {
|
||||||
@ -16,6 +16,7 @@ export default class PaymentAPI {
|
|||||||
return res?.data;
|
return res?.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO, type the response
|
||||||
static async confirmPaymentSchedule (setup_intent_id: string, cart_items: CartItems): Promise<any> {
|
static async confirmPaymentSchedule (setup_intent_id: string, cart_items: CartItems): Promise<any> {
|
||||||
const res: AxiosResponse = await apiClient.post(`/api/payments/confirm_payment_schedule`, {
|
const res: AxiosResponse = await apiClient.post(`/api/payments/confirm_payment_schedule`, {
|
||||||
setup_intent_id,
|
setup_intent_id,
|
||||||
@ -23,5 +24,13 @@ export default class PaymentAPI {
|
|||||||
});
|
});
|
||||||
return res?.data;
|
return res?.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static async updateCard (user_id: number, stp_payment_method_id: string): Promise<UpdateCardResponse> {
|
||||||
|
const res: AxiosResponse = await apiClient.post(`/api/payments/update_card`, {
|
||||||
|
user_id,
|
||||||
|
payment_method_id: stp_payment_method_id,
|
||||||
|
});
|
||||||
|
return res?.data;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -9,10 +9,12 @@ interface FabButtonProps {
|
|||||||
icon?: ReactNode,
|
icon?: ReactNode,
|
||||||
className?: string,
|
className?: string,
|
||||||
disabled?: boolean,
|
disabled?: boolean,
|
||||||
|
type?: 'submit' | 'reset' | 'button',
|
||||||
|
form?: string,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export const FabButton: React.FC<FabButtonProps> = ({ onClick, icon, className, disabled, children }) => {
|
export const FabButton: React.FC<FabButtonProps> = ({ onClick, icon, className, disabled, type, form, children }) => {
|
||||||
/**
|
/**
|
||||||
* Check if the current component was provided an icon to display
|
* Check if the current component was provided an icon to display
|
||||||
*/
|
*/
|
||||||
@ -30,10 +32,12 @@ export const FabButton: React.FC<FabButtonProps> = ({ onClick, icon, className,
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button onClick={handleClick} disabled={disabled} className={`fab-button ${className ? className : ''}`}>
|
<button type={type} form={form} onClick={handleClick} disabled={disabled} className={`fab-button ${className ? className : ''}`}>
|
||||||
{hasIcon() && <span className="fab-button--icon">{icon}</span>}
|
{hasIcon() && <span className="fab-button--icon">{icon}</span>}
|
||||||
{children}
|
{children}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
FabButton.defaultProps = { type: 'button' };
|
||||||
|
|
||||||
|
@ -11,13 +11,18 @@ import PaymentScheduleAPI from '../api/payment-schedule';
|
|||||||
import { DocumentFilters } from './document-filters';
|
import { DocumentFilters } from './document-filters';
|
||||||
import { PaymentSchedulesTable } from './payment-schedules-table';
|
import { PaymentSchedulesTable } from './payment-schedules-table';
|
||||||
import { FabButton } from './fab-button';
|
import { FabButton } from './fab-button';
|
||||||
|
import { User } from '../models/user';
|
||||||
|
|
||||||
declare var Application: IApplication;
|
declare var Application: IApplication;
|
||||||
|
|
||||||
|
interface PaymentSchedulesListProps {
|
||||||
|
currentUser: User
|
||||||
|
}
|
||||||
|
|
||||||
const PAGE_SIZE = 20;
|
const PAGE_SIZE = 20;
|
||||||
const paymentSchedulesList = PaymentScheduleAPI.list({ query: { page: 1, size: 20 } });
|
const paymentSchedulesList = PaymentScheduleAPI.list({ query: { page: 1, size: 20 } });
|
||||||
|
|
||||||
const PaymentSchedulesList: React.FC = () => {
|
const PaymentSchedulesList: React.FC<PaymentSchedulesListProps> = ({ currentUser }) => {
|
||||||
const { t } = useTranslation('admin');
|
const { t } = useTranslation('admin');
|
||||||
|
|
||||||
const [paymentSchedules, setPaymentSchedules] = useState(paymentSchedulesList.read());
|
const [paymentSchedules, setPaymentSchedules] = useState(paymentSchedulesList.read());
|
||||||
@ -88,7 +93,7 @@ const PaymentSchedulesList: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
{!hasSchedules() && <div>{t('app.admin.invoices.payment_schedules.no_payment_schedules')}</div>}
|
{!hasSchedules() && <div>{t('app.admin.invoices.payment_schedules.no_payment_schedules')}</div>}
|
||||||
{hasSchedules() && <div className="schedules-list">
|
{hasSchedules() && <div className="schedules-list">
|
||||||
<PaymentSchedulesTable paymentSchedules={paymentSchedules} showCustomer={true} refreshList={handleRefreshList} />
|
<PaymentSchedulesTable paymentSchedules={paymentSchedules} showCustomer={true} refreshList={handleRefreshList} operator={currentUser} />
|
||||||
{hasMoreSchedules() && <FabButton className="load-more" onClick={handleLoadMore}>{t('app.admin.invoices.payment_schedules.load_more')}</FabButton>}
|
{hasMoreSchedules() && <FabButton className="load-more" onClick={handleLoadMore}>{t('app.admin.invoices.payment_schedules.load_more')}</FabButton>}
|
||||||
</div>}
|
</div>}
|
||||||
</div>
|
</div>
|
||||||
@ -96,12 +101,12 @@ const PaymentSchedulesList: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const PaymentSchedulesListWrapper: React.FC = () => {
|
const PaymentSchedulesListWrapper: React.FC<PaymentSchedulesListProps> = ({ currentUser }) => {
|
||||||
return (
|
return (
|
||||||
<Loader>
|
<Loader>
|
||||||
<PaymentSchedulesList />
|
<PaymentSchedulesList currentUser={currentUser} />
|
||||||
</Loader>
|
</Loader>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Application.Components.component('paymentSchedulesList', react2angular(PaymentSchedulesListWrapper));
|
Application.Components.component('paymentSchedulesList', react2angular(PaymentSchedulesListWrapper, ['currentUser']));
|
||||||
|
@ -14,23 +14,33 @@ import { FabModal } from './fab-modal';
|
|||||||
import PaymentScheduleAPI from '../api/payment-schedule';
|
import PaymentScheduleAPI from '../api/payment-schedule';
|
||||||
import { StripeElements } from './stripe-elements';
|
import { StripeElements } from './stripe-elements';
|
||||||
import { StripeConfirm } from './stripe-confirm';
|
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;
|
declare var Fablab: IFablab;
|
||||||
|
|
||||||
interface PaymentSchedulesTableProps {
|
interface PaymentSchedulesTableProps {
|
||||||
paymentSchedules: Array<PaymentSchedule>,
|
paymentSchedules: Array<PaymentSchedule>,
|
||||||
showCustomer?: boolean,
|
showCustomer?: boolean,
|
||||||
refreshList: () => void
|
refreshList: () => void,
|
||||||
|
operator: User,
|
||||||
}
|
}
|
||||||
|
|
||||||
const PaymentSchedulesTableComponent: React.FC<PaymentSchedulesTableProps> = ({ paymentSchedules, showCustomer, refreshList }) => {
|
const PaymentSchedulesTableComponent: React.FC<PaymentSchedulesTableProps> = ({ paymentSchedules, showCustomer, refreshList, operator }) => {
|
||||||
const { t } = useTranslation('admin');
|
const { t } = useTranslation('admin');
|
||||||
|
|
||||||
const [showExpanded, setShowExpanded] = useState<Map<number, boolean>>(new Map());
|
const [showExpanded, setShowExpanded] = useState<Map<number, boolean>>(new Map());
|
||||||
const [showConfirmCashing, setShowConfirmCashing] = useState<boolean>(false);
|
const [showConfirmCashing, setShowConfirmCashing] = useState<boolean>(false);
|
||||||
const [showResolveAction, setShowResolveAction] = useState<boolean>(false);
|
const [showResolveAction, setShowResolveAction] = useState<boolean>(false);
|
||||||
const [isConfirmActionDisabled, setConfirmActionDisabled] = useState<boolean>(true);
|
const [isConfirmActionDisabled, setConfirmActionDisabled] = useState<boolean>(true);
|
||||||
|
const [showUpdateCard, setShowUpdateCard] = useState<boolean>(false);
|
||||||
const [tempDeadline, setTempDeadline] = useState<PaymentScheduleItem>(null);
|
const [tempDeadline, setTempDeadline] = useState<PaymentScheduleItem>(null);
|
||||||
|
const [tempSchedule, setTempSchedule] = useState<PaymentSchedule>(null);
|
||||||
|
const [canSubmitUpdateCard, setCanSubmitUpdateCard] = useState<boolean>(true);
|
||||||
|
const [errors, setErrors] = useState<string>(null);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if the requested payment schedule is displayed with its deadlines (PaymentScheduleItem) or without them
|
* Check if the requested payment schedule is displayed with its deadlines (PaymentScheduleItem) or without them
|
||||||
@ -123,7 +133,7 @@ const PaymentSchedulesTableComponent: React.FC<PaymentSchedulesTableProps> = ({
|
|||||||
/**
|
/**
|
||||||
* Return the action button(s) for the given deadline
|
* 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) {
|
switch (item.state) {
|
||||||
case PaymentScheduleItemState.Paid:
|
case PaymentScheduleItemState.Paid:
|
||||||
return downloadButton(TargetType.Invoice, item.invoice_id);
|
return downloadButton(TargetType.Invoice, item.invoice_id);
|
||||||
@ -143,7 +153,7 @@ const PaymentSchedulesTableComponent: React.FC<PaymentSchedulesTableProps> = ({
|
|||||||
);
|
);
|
||||||
case PaymentScheduleItemState.RequirePaymentMethod:
|
case PaymentScheduleItemState.RequirePaymentMethod:
|
||||||
return (
|
return (
|
||||||
<FabButton onClick={handleUpdateCard(item)}
|
<FabButton onClick={handleUpdateCard(item, schedule)}
|
||||||
icon={<i className="fas fa-credit-card" />}>
|
icon={<i className="fas fa-credit-card" />}>
|
||||||
{t('app.admin.invoices.schedules_table.update_card')}
|
{t('app.admin.invoices.schedules_table.update_card')}
|
||||||
</FabButton>
|
</FabButton>
|
||||||
@ -222,18 +232,63 @@ const PaymentSchedulesTableComponent: React.FC<PaymentSchedulesTableProps> = ({
|
|||||||
/**
|
/**
|
||||||
* Callback triggered when the user's clicks on the "update card" button: show a modal to input a new card
|
* 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 => {
|
return (): void => {
|
||||||
/*
|
setTempDeadline(item);
|
||||||
TODO
|
setTempSchedule(paymentSchedule);
|
||||||
- Notify the customer, collect new payment information, and create a new payment method
|
toggleUpdateCardModal();
|
||||||
- Attach the payment method to the customer
|
|
||||||
- Update the default payment method
|
|
||||||
- Pay the invoice using the new payment method
|
|
||||||
*/
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 (
|
||||||
|
<div className="stripe-modal-icons">
|
||||||
|
<i className="fa fa-lock fa-2x m-r-sm pos-rlt" />
|
||||||
|
<img src={stripeLogo} alt="powered by stripe" />
|
||||||
|
<img src={mastercardLogo} alt="mastercard" />
|
||||||
|
<img src={visaLogo} alt="visa" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<table className="schedules-table">
|
<table className="schedules-table">
|
||||||
@ -278,7 +333,7 @@ const PaymentSchedulesTableComponent: React.FC<PaymentSchedulesTableProps> = ({
|
|||||||
<td>{formatDate(item.due_date)}</td>
|
<td>{formatDate(item.due_date)}</td>
|
||||||
<td>{formatPrice(item.amount)}</td>
|
<td>{formatPrice(item.amount)}</td>
|
||||||
<td>{formatState(item)}</td>
|
<td>{formatState(item)}</td>
|
||||||
<td>{itemButtons(item)}</td>
|
<td>{itemButtons(item, p)}</td>
|
||||||
</tr>)}
|
</tr>)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@ -314,6 +369,31 @@ const PaymentSchedulesTableComponent: React.FC<PaymentSchedulesTableProps> = ({
|
|||||||
preventConfirm={isConfirmActionDisabled}>
|
preventConfirm={isConfirmActionDisabled}>
|
||||||
{tempDeadline && <StripeConfirm clientSecret={tempDeadline.client_secret} onResponse={toggleConfirmActionButton} />}
|
{tempDeadline && <StripeConfirm clientSecret={tempDeadline.client_secret} onResponse={toggleConfirmActionButton} />}
|
||||||
</FabModal>
|
</FabModal>
|
||||||
|
<FabModal title={t('app.admin.invoices.schedules_table.update_card')}
|
||||||
|
isOpen={showUpdateCard}
|
||||||
|
toggleModal={toggleUpdateCardModal}
|
||||||
|
closeButton={false}
|
||||||
|
customFooter={logoFooter()}
|
||||||
|
className="update-card-modal">
|
||||||
|
{tempDeadline && tempSchedule && <StripeCardUpdate onSubmit={handleCardUpdateSubmit}
|
||||||
|
onSuccess={handleCardUpdateSuccess}
|
||||||
|
onError={handleCardUpdateError}
|
||||||
|
customerId={tempSchedule.user.id}
|
||||||
|
operator={operator}
|
||||||
|
className="card-form" >
|
||||||
|
{errors && <div className="stripe-errors">
|
||||||
|
{errors}
|
||||||
|
</div>}
|
||||||
|
</StripeCardUpdate>}
|
||||||
|
<div className="submit-card">
|
||||||
|
{canSubmitUpdateCard && <button type="submit" disabled={!canSubmitUpdateCard} form="stripe-card" className="submit-card-btn">{t('app.admin.invoices.schedules_table.validate_button')}</button>}
|
||||||
|
{!canSubmitUpdateCard && <div className="payment-pending">
|
||||||
|
<div className="fa-2x">
|
||||||
|
<i className="fas fa-circle-notch fa-spin" />
|
||||||
|
</div>
|
||||||
|
</div>}
|
||||||
|
</div>
|
||||||
|
</FabModal>
|
||||||
</StripeElements>
|
</StripeElements>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -322,10 +402,10 @@ const PaymentSchedulesTableComponent: React.FC<PaymentSchedulesTableProps> = ({
|
|||||||
PaymentSchedulesTableComponent.defaultProps = { showCustomer: false };
|
PaymentSchedulesTableComponent.defaultProps = { showCustomer: false };
|
||||||
|
|
||||||
|
|
||||||
export const PaymentSchedulesTable: React.FC<PaymentSchedulesTableProps> = ({ paymentSchedules, showCustomer, refreshList }) => {
|
export const PaymentSchedulesTable: React.FC<PaymentSchedulesTableProps> = ({ paymentSchedules, showCustomer, refreshList, operator }) => {
|
||||||
return (
|
return (
|
||||||
<Loader>
|
<Loader>
|
||||||
<PaymentSchedulesTableComponent paymentSchedules={paymentSchedules} showCustomer={showCustomer} refreshList={refreshList} />
|
<PaymentSchedulesTableComponent paymentSchedules={paymentSchedules} showCustomer={showCustomer} refreshList={refreshList} operator={operator} />
|
||||||
</Loader>
|
</Loader>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
101
app/frontend/src/javascript/components/stripe-card-update.tsx
Normal file
101
app/frontend/src/javascript/components/stripe-card-update.tsx
Normal file
@ -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<StripeCardUpdateProps> = ({ 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<void> => {
|
||||||
|
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 (
|
||||||
|
<form onSubmit={handleSubmit} id="stripe-card" className={className}>
|
||||||
|
<CardElement options={cardOptions} />
|
||||||
|
{children}
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
@ -1,3 +1,5 @@
|
|||||||
|
import { StripeIbanElement } from '@stripe/stripe-js';
|
||||||
|
|
||||||
export enum PaymentScheduleItemState {
|
export enum PaymentScheduleItemState {
|
||||||
New = 'new',
|
New = 'new',
|
||||||
Pending = 'pending',
|
Pending = 'pending',
|
||||||
@ -42,6 +44,7 @@ export interface PaymentSchedule {
|
|||||||
created_at: Date,
|
created_at: Date,
|
||||||
chained_footprint: boolean,
|
chained_footprint: boolean,
|
||||||
user: {
|
user: {
|
||||||
|
id: number,
|
||||||
name: string
|
name: string
|
||||||
},
|
},
|
||||||
operator: {
|
operator: {
|
||||||
@ -65,3 +68,12 @@ export interface CashCheckResponse {
|
|||||||
state: PaymentScheduleItemState,
|
state: PaymentScheduleItemState,
|
||||||
payment_method: PaymentMethod
|
payment_method: PaymentMethod
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RefreshItemResponse {
|
||||||
|
state: 'refreshed'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PayItemResponse {
|
||||||
|
status: 'draft' | 'open' | 'paid' | 'uncollectible' | 'void',
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
@ -24,3 +24,8 @@ export interface CartItems {
|
|||||||
subscription?: SubscriptionRequest,
|
subscription?: SubscriptionRequest,
|
||||||
coupon_code?: string
|
coupon_code?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UpdateCardResponse {
|
||||||
|
updated: boolean,
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
@ -121,3 +121,60 @@
|
|||||||
color: black;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -35,7 +35,7 @@
|
|||||||
</uib-tab>
|
</uib-tab>
|
||||||
|
|
||||||
<uib-tab heading="{{ 'app.admin.invoices.payment_schedules_list' | translate }}" ng-show="$root.modules.invoicing" index="4">
|
<uib-tab heading="{{ 'app.admin.invoices.payment_schedules_list' | translate }}" ng-show="$root.modules.invoicing" index="4">
|
||||||
<payment-schedules-list />
|
<payment-schedules-list current-user="currentUser" />
|
||||||
</uib-tab>
|
</uib-tab>
|
||||||
|
|
||||||
<uib-tab heading="{{ 'app.admin.invoices.invoicing_settings' | translate }}" index="1" class="invoices-settings">
|
<uib-tab heading="{{ 'app.admin.invoices.invoicing_settings' | translate }}" index="1" class="invoices-settings">
|
||||||
|
@ -2,19 +2,15 @@
|
|||||||
|
|
||||||
# Check the access policies for API::PaymentSchedulesController
|
# Check the access policies for API::PaymentSchedulesController
|
||||||
class PaymentSchedulePolicy < ApplicationPolicy
|
class PaymentSchedulePolicy < ApplicationPolicy
|
||||||
def list?
|
%w[list? cash_check?].each do |action|
|
||||||
|
define_method action do
|
||||||
user.admin? || user.manager?
|
user.admin? || user.manager?
|
||||||
end
|
end
|
||||||
|
|
||||||
def cash_check?
|
|
||||||
user.admin? || user.manager?
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def refresh_item?
|
%w[refresh_item? download? pay_item?].each do |action|
|
||||||
|
define_method action do
|
||||||
user.admin? || user.manager? || (record.invoicing_profile.user_id == user.id)
|
user.admin? || user.manager? || (record.invoicing_profile.user_id == user.id)
|
||||||
end
|
end
|
||||||
|
|
||||||
def download?
|
|
||||||
user.admin? || user.manager? || (record.invoicing_profile.user_id == user.id)
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
@ -4,6 +4,7 @@ json.extract! payment_schedule, :id, :reference, :created_at, :payment_method
|
|||||||
json.total payment_schedule.total / 100.00
|
json.total payment_schedule.total / 100.00
|
||||||
json.chained_footprint payment_schedule.check_footprint
|
json.chained_footprint payment_schedule.check_footprint
|
||||||
json.user do
|
json.user do
|
||||||
|
json.id payment_schedule.invoicing_profile&.user&.id
|
||||||
json.name payment_schedule.invoicing_profile.full_name
|
json.name payment_schedule.invoicing_profile.full_name
|
||||||
end
|
end
|
||||||
if payment_schedule.operator_profile
|
if payment_schedule.operator_profile
|
||||||
|
@ -669,6 +669,7 @@ en:
|
|||||||
confirm_button: "Confirm"
|
confirm_button: "Confirm"
|
||||||
resolve_action: "Resolve the action"
|
resolve_action: "Resolve the action"
|
||||||
ok_button: "OK"
|
ok_button: "OK"
|
||||||
|
validate_button: "Validate the new card"
|
||||||
document_filters:
|
document_filters:
|
||||||
reference: "Reference"
|
reference: "Reference"
|
||||||
customer: "Customer"
|
customer: "Customer"
|
||||||
|
@ -669,6 +669,7 @@ fr:
|
|||||||
confirm_button: "Confirmer"
|
confirm_button: "Confirmer"
|
||||||
resolve_action: "Résoudre l'action"
|
resolve_action: "Résoudre l'action"
|
||||||
ok_button: "OK"
|
ok_button: "OK"
|
||||||
|
validate_button: "Valider la nouvelle carte"
|
||||||
document_filters:
|
document_filters:
|
||||||
reference: "Référence"
|
reference: "Référence"
|
||||||
customer: "Client"
|
customer: "Client"
|
||||||
|
@ -116,6 +116,7 @@ Rails.application.routes.draw do
|
|||||||
get 'download', on: :member
|
get 'download', on: :member
|
||||||
post 'items/:id/cash_check', action: 'cash_check', on: :collection
|
post 'items/:id/cash_check', action: 'cash_check', on: :collection
|
||||||
post 'items/:id/refresh_item', action: 'refresh_item', on: :collection
|
post 'items/:id/refresh_item', action: 'refresh_item', on: :collection
|
||||||
|
post 'items/:id/pay_item', action: 'pay_item', on: :collection
|
||||||
end
|
end
|
||||||
|
|
||||||
resources :i_calendar, only: %i[index create destroy] do
|
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/online_payment_status' => 'payments/online_payment_status'
|
||||||
get 'payments/setup_intent/:user_id' => 'payments#setup_intent'
|
get 'payments/setup_intent/:user_id' => 'payments#setup_intent'
|
||||||
post 'payments/confirm_payment_schedule' => 'payments#confirm_payment_schedule'
|
post 'payments/confirm_payment_schedule' => 'payments#confirm_payment_schedule'
|
||||||
|
post 'payments/update_card' => 'payments#update_card'
|
||||||
|
|
||||||
# FabAnalytics
|
# FabAnalytics
|
||||||
get 'analytics/data' => 'analytics#data'
|
get 'analytics/data' => 'analytics#data'
|
||||||
|
Loading…
x
Reference in New Issue
Block a user