1
0
mirror of https://github.com/LaCasemate/fab-manager.git synced 2025-02-19 13:54:25 +01:00

WIP: stripe update card

This commit is contained in:
Sylvain 2021-02-09 12:09:26 +01:00
parent d1584604b3
commit b0ef9e097d
20 changed files with 348 additions and 44 deletions

View File

@ -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

View File

@ -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'

View File

@ -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)

View File

@ -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

View File

@ -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)

View File

@ -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<void> {
async refreshItem(paymentScheduleItemId: number): Promise<RefreshItemResponse> {
const res: AxiosResponse = await apiClient.post(`/api/payment_schedules/items/${paymentScheduleItemId}/refresh_item`);
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>> {
const api = new PaymentScheduleAPI();
return wrapPromise(api.list(query));

View File

@ -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<PaymentConfirmation> {
@ -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<any> {
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<UpdateCardResponse> {
const res: AxiosResponse = await apiClient.post(`/api/payments/update_card`, {
user_id,
payment_method_id: stp_payment_method_id,
});
return res?.data;
}
}

View File

@ -9,10 +9,12 @@ interface FabButtonProps {
icon?: ReactNode,
className?: string,
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
*/
@ -30,10 +32,12 @@ export const FabButton: React.FC<FabButtonProps> = ({ onClick, icon, className,
}
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>}
{children}
</button>
);
}
FabButton.defaultProps = { type: 'button' };

View File

@ -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<PaymentSchedulesListProps> = ({ currentUser }) => {
const { t } = useTranslation('admin');
const [paymentSchedules, setPaymentSchedules] = useState(paymentSchedulesList.read());
@ -88,7 +93,7 @@ const PaymentSchedulesList: React.FC = () => {
</div>
{!hasSchedules() && <div>{t('app.admin.invoices.payment_schedules.no_payment_schedules')}</div>}
{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>}
</div>}
</div>
@ -96,12 +101,12 @@ const PaymentSchedulesList: React.FC = () => {
}
const PaymentSchedulesListWrapper: React.FC = () => {
const PaymentSchedulesListWrapper: React.FC<PaymentSchedulesListProps> = ({ currentUser }) => {
return (
<Loader>
<PaymentSchedulesList />
<PaymentSchedulesList currentUser={currentUser} />
</Loader>
);
}
Application.Components.component('paymentSchedulesList', react2angular(PaymentSchedulesListWrapper));
Application.Components.component('paymentSchedulesList', react2angular(PaymentSchedulesListWrapper, ['currentUser']));

View File

@ -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<PaymentSchedule>,
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 [showExpanded, setShowExpanded] = useState<Map<number, boolean>>(new Map());
const [showConfirmCashing, setShowConfirmCashing] = useState<boolean>(false);
const [showResolveAction, setShowResolveAction] = useState<boolean>(false);
const [isConfirmActionDisabled, setConfirmActionDisabled] = useState<boolean>(true);
const [showUpdateCard, setShowUpdateCard] = useState<boolean>(false);
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
@ -123,7 +133,7 @@ const PaymentSchedulesTableComponent: React.FC<PaymentSchedulesTableProps> = ({
/**
* 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<PaymentSchedulesTableProps> = ({
);
case PaymentScheduleItemState.RequirePaymentMethod:
return (
<FabButton onClick={handleUpdateCard(item)}
<FabButton onClick={handleUpdateCard(item, schedule)}
icon={<i className="fas fa-credit-card" />}>
{t('app.admin.invoices.schedules_table.update_card')}
</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
*/
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 (
<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 (
<div>
<table className="schedules-table">
@ -278,7 +333,7 @@ const PaymentSchedulesTableComponent: React.FC<PaymentSchedulesTableProps> = ({
<td>{formatDate(item.due_date)}</td>
<td>{formatPrice(item.amount)}</td>
<td>{formatState(item)}</td>
<td>{itemButtons(item)}</td>
<td>{itemButtons(item, p)}</td>
</tr>)}
</tbody>
</table>
@ -314,6 +369,31 @@ const PaymentSchedulesTableComponent: React.FC<PaymentSchedulesTableProps> = ({
preventConfirm={isConfirmActionDisabled}>
{tempDeadline && <StripeConfirm clientSecret={tempDeadline.client_secret} onResponse={toggleConfirmActionButton} />}
</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>
</div>
</div>
@ -322,10 +402,10 @@ const PaymentSchedulesTableComponent: React.FC<PaymentSchedulesTableProps> = ({
PaymentSchedulesTableComponent.defaultProps = { showCustomer: false };
export const PaymentSchedulesTable: React.FC<PaymentSchedulesTableProps> = ({ paymentSchedules, showCustomer, refreshList }) => {
export const PaymentSchedulesTable: React.FC<PaymentSchedulesTableProps> = ({ paymentSchedules, showCustomer, refreshList, operator }) => {
return (
<Loader>
<PaymentSchedulesTableComponent paymentSchedules={paymentSchedules} showCustomer={showCustomer} refreshList={refreshList} />
<PaymentSchedulesTableComponent paymentSchedules={paymentSchedules} showCustomer={showCustomer} refreshList={refreshList} operator={operator} />
</Loader>
);
}

View 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>
);
}

View File

@ -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
}

View File

@ -24,3 +24,8 @@ export interface CartItems {
subscription?: SubscriptionRequest,
coupon_code?: string
}
export interface UpdateCardResponse {
updated: boolean,
error?: string
}

View File

@ -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;
}
}
}
}

View File

@ -35,7 +35,7 @@
</uib-tab>
<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 heading="{{ 'app.admin.invoices.invoicing_settings' | translate }}" index="1" class="invoices-settings">

View File

@ -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

View File

@ -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

View File

@ -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"

View File

@ -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"

View File

@ -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'