1
0
mirror of https://github.com/LaCasemate/fab-manager.git synced 2025-01-29 18:52:22 +01:00

WIP: using <stripe-modal> in cart directive

This commit is contained in:
Sylvain 2020-12-01 17:55:23 +01:00
parent 9813c5d27b
commit 4ca2299776
11 changed files with 252 additions and 112 deletions

View File

@ -0,0 +1,14 @@
import apiClient from './api-client';
import { AxiosResponse } from 'axios';
import { CartItems, PaymentConfirmation } from '../models/payment';
export default class PaymentAPI {
static async confirm (stp_payment_method_id: string, cart_items: CartItems): Promise<PaymentConfirmation> {
const res: AxiosResponse = await apiClient.post(`/api/payment/confirm`, {
payment_method_id: stp_payment_method_id,
cart_items
});
return res?.data;
}
}

View File

@ -0,0 +1,18 @@
import apiClient from './api-client';
import { AxiosResponse } from 'axios';
import wrapPromise, { IWrapPromise } from '../lib/wrap-promise';
import { CartItems } from '../models/payment';
import { ComputePriceResult } from '../models/price';
export default class PriceAPI {
async compute (cartItems: CartItems): Promise<ComputePriceResult> {
const res: AxiosResponse = await apiClient.post(`/api/prices/compute`, cartItems);
return res?.data?.custom_asset;
}
static compute (cartItems: CartItems): IWrapPromise<ComputePriceResult> {
const api = new PriceAPI();
return wrapPromise(api.compute(cartItems));
}
}

View File

@ -0,0 +1,17 @@
import apiClient from './api-client';
import { AxiosResponse } from 'axios';
import wrapPromise, { IWrapPromise } from '../lib/wrap-promise';
import { Wallet } from '../models/wallet';
export default class WalletAPI {
async getByUser (user_id: number): Promise<Wallet> {
const res: AxiosResponse = await apiClient.get(`/api/wallet/by_user/${user_id}`);
return res?.data;
}
static getByUser (user_id: number): IWrapPromise<Wallet> {
const api = new WalletAPI();
return wrapPromise(api.getByUser(user_id));
}
}

View File

@ -1,19 +1,26 @@
import React, { FormEvent } from 'react';
import { CardElement, useElements, useStripe } from '@stripe/react-stripe-js';
import { PaymentMethod } from "@stripe/stripe-js";
import PaymentAPI from '../api/payment';
import { CartItems, PaymentConfirmation } from '../models/payment';
import { useTranslation } from 'react-i18next';
interface StripeFormProps {
onSubmit: () => void,
onSuccess: (paymentMethod: PaymentMethod) => void,
onError: (message: string) => void,
className?: string,
processPayment?: boolean,
cartItems?: CartItems
}
/**
* A form component to collect the credit card details and to create the payment method on Stripe.
* The form validation button must be created elsewhere, using the attribute form="stripe-form".
*/
export const StripeForm: React.FC<StripeFormProps> = ({ onSubmit, onSuccess, onError, children, className }) => {
export const StripeForm: React.FC<StripeFormProps> = ({ onSubmit, onSuccess, onError, children, className, processPayment , cartItems}) => {
const { t } = useTranslation('shared');
const stripe = useStripe();
const elements = useElements();
@ -37,11 +44,49 @@ export const StripeForm: React.FC<StripeFormProps> = ({ onSubmit, onSuccess, onE
if (error) {
onError(error.message);
} else {
if (processPayment) {
// process the full payment pipeline, including SCA validation
const res = await PaymentAPI.confirm(paymentMethod.id, cartItems);
await handleServerConfirmation(res, paymentMethod);
} else {
// we don't want to process the payment, only return the payment method
onSuccess(paymentMethod);
}
}
}
/**
* Process the server response about the Strong-customer authentication (SCA)
*/
const handleServerConfirmation = async (response: PaymentConfirmation, paymentMethod: PaymentMethod) => {
if (response.error) {
if (response.error.statusText) {
onError(response.error.statusText);
} else {
onError(`${t('app.shared.messages.payment_card_error')} ${response.error}`);
}
} else if (response.requires_action) {
// Use Stripe.js to handle required card action
const result = await stripe.handleCardAction(response.payment_intent_client_secret);
if (result.error) {
onError(result.error.message);
} else {
// The card action has been handled
// The PaymentIntent can be confirmed again on the server
try {
const confirmation = await PaymentAPI.confirm(result.paymentIntent.id, cartItems);
await handleServerConfirmation(confirmation, paymentMethod);
} catch (e) {
onError(e);
}
}
} else {
onSuccess(paymentMethod);
}
}
/**
* Options for the Stripe's card input
*/

View File

@ -1,5 +1,6 @@
/**
* This component enables the user to input his card data.
* This component enables the user to input his card data or process payments.
* Supports Strong-Customer Authentication (SCA).
*/
import React, { ChangeEvent, ReactNode, useEffect, useState } from 'react';
@ -11,9 +12,7 @@ import { useTranslation } from 'react-i18next';
import { FabModal, ModalSize } from './fab-modal';
import { PaymentMethod } from '@stripe/stripe-js';
import { WalletInfo } from './wallet-info';
import { Reservation } from '../models/reservation';
import { User } from '../models/user';
import { Wallet } from '../models/wallet';
import CustomAssetAPI from '../api/custom-asset';
import { CustomAssetName } from '../models/custom-asset';
import { PaymentSchedule } from '../models/payment-schedule';
@ -24,6 +23,9 @@ import { StripeForm } from './stripe-form';
import stripeLogo from '../../../images/powered_by_stripe.png';
import mastercardLogo from '../../../images/mastercard.png';
import visaLogo from '../../../images/visa.png';
import { CartItems } from '../models/payment';
import WalletAPI from '../api/wallet';
import PriceAPI from '../api/price';
declare var Application: IApplication;
declare var Fablab: IFablab;
@ -32,34 +34,37 @@ interface StripeModalProps {
isOpen: boolean,
toggleModal: () => void,
afterSuccess: (paymentMethod: PaymentMethod) => void,
reservation: Reservation,
cartItems: CartItems,
currentUser: User,
wallet: Wallet,
price: number,
schedule: PaymentSchedule
schedule: PaymentSchedule,
processPayment?: boolean,
}
const cgvFile = CustomAssetAPI.get(CustomAssetName.CgvFile);
const StripeModal: React.FC<StripeModalProps> = ({ isOpen, toggleModal, afterSuccess, reservation, currentUser, wallet, price, schedule }) => {
const StripeModal: React.FC<StripeModalProps> = ({ isOpen, toggleModal, afterSuccess, cartItems, currentUser, schedule , processPayment = true }) => {
const [remainingPrice, setRemainingPrice] = useState(0);
const userWallet = WalletAPI.getByUser(cartItems.reservation?.user_id || cartItems.subscription?.user_id);
const priceInfo = PriceAPI.compute(cartItems);
const { t } = useTranslation('shared');
const cgv = cgvFile.read();
const wallet = userWallet.read();
const price = priceInfo.read();
const [errors, setErrors] = useState(null);
const [submitState, setSubmitState] = useState(false);
const [tos, setTos] = useState(false);
/**
* Refresh the remaining price on each display
*/
useEffect(() => {
const wLib = new WalletLib(wallet);
setRemainingPrice(wLib.computeRemainingPrice(price));
setRemainingPrice(wLib.computeRemainingPrice(price.price));
})
const { t } = useTranslation('shared');
const cgv = cgvFile.read();
const [errors, setErrors] = useState(null);
const [submitState, setSubmitState] = useState(false);
const [tos, setTos] = useState(false);
/**
* Check if there is currently an error to display
*/
@ -68,12 +73,15 @@ const StripeModal: React.FC<StripeModalProps> = ({ isOpen, toggleModal, afterSuc
}
/**
* Check if the Terms of Sales document is set
* Check if the user accepts the Terms of Sales document
*/
const hasCgv = (): boolean => {
return cgv != null;
}
/**
* Triggered when the user accepts or declines the Terms of Sales
*/
const toggleTos = (event: ChangeEvent): void => {
setTos(!tos);
}
@ -110,16 +118,32 @@ const StripeModal: React.FC<StripeModalProps> = ({ isOpen, toggleModal, afterSuc
setSubmitState(true);
}
const handleFormSuccess = (paymentMethod: PaymentMethod): void => {
/**
* After sending the form with success, process the resulting payment method
*/
const handleFormSuccess = async (paymentMethod: PaymentMethod): Promise<void> => {
setSubmitState(false);
afterSuccess(paymentMethod);
}
/**
* When stripe-form raise an error, it is handled by this callback which display it in the modal.
*/
const handleFormError = (message: string): void => {
setSubmitState(false);
setErrors(message);
}
/**
* Check the form can be submitted.
* => We're not currently already submitting the form, and, if the terms of service are enabled, the user agrees with them.
*/
const canSubmit = (): boolean => {
let terms = true;
if (hasCgv()) { terms = tos; }
return !submitState && terms;
}
return (
<FabModal title={t('app.shared.stripe.online_payment')}
@ -129,9 +153,14 @@ const StripeModal: React.FC<StripeModalProps> = ({ isOpen, toggleModal, afterSuc
closeButton={false}
customFooter={logoFooter()}
className="stripe-modal">
<WalletInfo reservation={reservation} currentUser={currentUser} wallet={wallet} price={price} />
<WalletInfo reservation={cartItems.reservation} currentUser={currentUser} wallet={wallet} price={price.price} />
<StripeElements>
<StripeForm onSubmit={handleSubmit} onSuccess={handleFormSuccess} onError={handleFormError} className="stripe-form">
<StripeForm onSubmit={handleSubmit}
onSuccess={handleFormSuccess}
onError={handleFormError}
className="stripe-form"
cartItems={cartItems}
processPayment={processPayment}>
{hasErrors() && <div className="stripe-errors">
{errors}
</div>}
@ -149,7 +178,7 @@ const StripeModal: React.FC<StripeModalProps> = ({ isOpen, toggleModal, afterSuc
</div>}
</StripeForm>
<button type="submit"
disabled={submitState}
disabled={!canSubmit()}
form="stripe-form"
className="validate-btn">
{t('app.shared.stripe.confirm_payment_of_', { AMOUNT: formatPrice(remainingPrice) })}
@ -159,12 +188,12 @@ const StripeModal: React.FC<StripeModalProps> = ({ isOpen, toggleModal, afterSuc
);
}
const StripeModalWrapper: React.FC<StripeModalProps> = ({ isOpen, toggleModal, afterSuccess, reservation, currentUser, wallet, price, schedule }) => {
const StripeModalWrapper: React.FC<StripeModalProps> = ({ isOpen, toggleModal, afterSuccess, currentUser, schedule , cartItems, processPayment}) => {
return (
<Loader>
<StripeModal isOpen={isOpen} toggleModal={toggleModal} afterSuccess={afterSuccess} reservation={reservation} currentUser={currentUser} wallet={wallet} price={price} schedule={schedule} />
<StripeModal isOpen={isOpen} toggleModal={toggleModal} afterSuccess={afterSuccess} currentUser={currentUser} schedule={schedule} processPayment={processPayment} cartItems={cartItems}/>
</Loader>
);
}
Application.Components.component('stripeModal', react2angular(StripeModalWrapper, ['isOpen', 'toggleModal', 'afterSuccess', 'reservation', 'currentUser', 'wallet', 'price', 'schedule']));
Application.Components.component('stripeModal', react2angular(StripeModalWrapper, ['isOpen', 'toggleModal', 'afterSuccess','currentUser', 'schedule', 'cartItems', 'processPayment']));

View File

@ -73,6 +73,12 @@ Application.Directives.directive('cart', ['$rootScope', '$uibModal', 'dialogs',
payment_schedule: null // the effective computed payment schedule
};
// online payments (stripe)
$scope.stripe = {
showModal: false,
cartItems: null
};
/**
* Add the provided slot to the shopping cart (state transition from free to 'about to be reserved')
* and increment the total amount of the cart if needed.
@ -303,6 +309,24 @@ Application.Directives.directive('cart', ['$rootScope', '$uibModal', 'dialogs',
}, 50);
};
/**
* This will open/close the stripe payment modal
*/
$scope.toggleStripeModal = () => {
setTimeout(() => {
$scope.stripe.showModal = !$scope.stripe.showModal;
$scope.$apply();
}, 50);
};
/**
* Invoked atfer a successful Stripe payment
*/
$scope.afterStripeSuccess = () => {
$scope.toggleStripeModal();
afterPayment($scope.reservation);
};
/* PRIVATE SCOPE */
/**
@ -669,77 +693,8 @@ Application.Directives.directive('cart', ['$rootScope', '$uibModal', 'dialogs',
* Open a modal window that allows the user to process a credit card payment for his current shopping cart.
*/
const payByStripe = function (reservation) {
$uibModal.open({
templateUrl: '/stripe/payment_modal.html',
size: 'md',
resolve: {
reservation () {
return reservation;
},
price () {
return Price.compute(mkRequestParams({ reservation }, $scope.coupon.applied)).$promise;
},
wallet () {
return Wallet.getWalletByUser({ user_id: reservation.user_id }).$promise;
},
cgv () {
return CustomAsset.get({ name: 'cgv-file' }).$promise;
},
coupon () {
return $scope.coupon.applied;
},
cartItems () {
let request = { reservation };
if (reservation.slots_attributes.length === 0 && reservation.plan_id) {
request = mkSubscription($scope.selectedPlan.id, reservation.user_id, $scope.schedule.requested_schedule, 'stripe');
} else {
request.reservation.payment_method = 'stripe';
}
return mkRequestParams(request, $scope.coupon.applied);
},
schedule () {
return $scope.schedule.payment_schedule;
},
stripeKey: ['Setting', function (Setting) { return Setting.get({ name: 'stripe_public_key' }).$promise; }]
},
controller: ['$scope', '$uibModalInstance', '$state', 'reservation', 'price', 'cgv', 'Auth', 'Reservation', 'wallet', 'helpers', '$filter', 'coupon', 'cartItems', 'stripeKey', 'schedule',
function ($scope, $uibModalInstance, $state, reservation, price, cgv, Auth, Reservation, wallet, helpers, $filter, coupon, cartItems, stripeKey, schedule) {
// user wallet amount
$scope.wallet = wallet;
// Global price (total of all items)
$scope.price = price.price;
// Price
$scope.amount = helpers.getAmountToPay(price.price, wallet.amount);
// Cart items
$scope.cartItems = cartItems;
// CGV
$scope.cgv = cgv.custom_asset;
// Reservation
$scope.reservation = reservation;
// Used in wallet info template to interpolate some translations
$scope.numberFilter = $filter('number');
// stripe publishable key
$scope.stripeKey = stripeKey.setting.value;
// Shows the schedule info in the modal
$scope.schedule = schedule;
/**
* Callback to handle the post-payment and reservation
*/
$scope.onPaymentSuccess = function (response) {
$uibModalInstance.close(response);
};
}
]
}).result.finally(null).then(function (reservation) { afterPayment(reservation); });
$scope.stripe.cartItems = mkRequestParams({ reservation }, $scope.coupon.applied);
$scope.toggleStripeModal();
};
/**
* Open a modal window that allows the user to process a local payment for his current shopping cart (admin only).
@ -755,6 +710,9 @@ Application.Directives.directive('cart', ['$rootScope', '$uibModal', 'dialogs',
price () {
return Price.compute(mkRequestParams({ reservation }, $scope.coupon.applied)).$promise;
},
cartItems () {
return mkRequestParams({ reservation }, $scope.coupon.applied);
},
wallet () {
return Wallet.getWalletByUser({ user_id: reservation.user_id }).$promise;
},
@ -768,8 +726,8 @@ Application.Directives.directive('cart', ['$rootScope', '$uibModal', 'dialogs',
return $scope.schedule;
}
},
controller: ['$scope', '$uibModalInstance', '$state', 'reservation', 'price', 'Auth', 'Reservation', 'Subscription', 'wallet', 'helpers', '$filter', 'coupon', 'selectedPlan', 'schedule',
function ($scope, $uibModalInstance, $state, reservation, price, Auth, Reservation, Subscription, wallet, helpers, $filter, coupon, selectedPlan, schedule) {
controller: ['$scope', '$uibModalInstance', '$state', 'reservation', 'price', 'Auth', 'Reservation', 'Subscription', 'wallet', 'helpers', '$filter', 'coupon', 'selectedPlan', 'schedule', 'cartItems',
function ($scope, $uibModalInstance, $state, reservation, price, Auth, Reservation, Subscription, wallet, helpers, $filter, coupon, selectedPlan, schedule, cartItems) {
// user wallet amount
$scope.wallet = wallet;
@ -779,8 +737,9 @@ Application.Directives.directive('cart', ['$rootScope', '$uibModal', 'dialogs',
// Price to pay (wallet deducted)
$scope.amount = helpers.getAmountToPay(price.price, wallet.amount);
// Reservation
// Reservation (simple & cartItems format)
$scope.reservation = reservation;
$scope.cartItems = cartItems;
// Subscription
$scope.plan = selectedPlan;

View File

@ -0,0 +1,26 @@
import { Reservation } from './reservation';
export interface PaymentConfirmation {
requires_action?: boolean,
payment_intent_client_secret?: string,
success?: boolean,
error?: {
statusText: string
},
}
export enum PaymentMethod {
Stripe = 'stripe',
Other = ''
}
export interface CartItems {
reservation: Reservation,
subscription: {
plan_id: number,
user_id: number,
payment_schedule: boolean,
payment_method: PaymentMethod
},
coupon_code: string
}

View File

@ -1,8 +1,27 @@
export interface Price {
id: number,
group_id: number,
plan_id: number,
priceable_type: string,
priceable_id: number,
amount: number
id: number,
group_id: number,
plan_id: number,
priceable_type: string,
priceable_id: number,
amount: number
}
export interface ComputePriceResult {
price: number,
price_without_coupon: number,
details?: {
slots: Array<{
start_at: Date,
price: number,
promo: boolean
}>
plan?: number
},
schedule?: {
items: Array<{
amount: number,
due_date: Date
}>
}
}

View File

@ -1,4 +1,5 @@
export interface ReservationSlot {
id?: number,
start_at: Date,
end_at: Date,
availability_id: number,
@ -10,6 +11,11 @@ export interface Reservation {
reservable_id: number,
reservable_type: string,
slots_attributes: Array<ReservationSlot>,
plan_id: number
payment_schedule: boolean
plan_id?: number,
nb_reserve_places?: number,
payment_schedule?: boolean,
tickets_attributes?: {
event_price_category_id: number,
booked: boolean,
},
}

View File

@ -199,3 +199,11 @@
</div>
</div>
<div ng-if="stripe.showModal">
<stripe-modal is-open="stripe.showModal"
toggle-modal="toggleStripeModal"
after-success="afterStripeSuccess"
cartItems="stripe.cartItems"
current-user="currentUser"
schedule="schedule.payment_schedule"/>
</div>

View File

@ -52,9 +52,8 @@
<stripe-modal is-open="isOpenStripeModal"
toggle-modal="toggleStripeModal"
after-success="afterCreatePaymentMethod"
reservation="reservation"
cart-items="cartItems"
current-user="currentUser"
wallet="wallet"
price="price"
schedule="schedule" />
schedule="schedule"
processPayment="false"/>
</div>