update flywire

This commit is contained in:
LittleBoy 2024-06-22 20:56:29 +08:00
parent f899760a86
commit 9095b4d9b1
12 changed files with 152 additions and 80 deletions

View File

@ -173,7 +173,8 @@ body #root .dashboard-layout{
.dashboard-menu-container { .dashboard-menu-container {
padding: var(--dashboard-layout-padding, 15px); padding: var(--dashboard-layout-padding, 15px);
position: sticky;
top:var(--dashboard-header-height, 50px);
.nav-item { .nav-item {
padding: 15px; padding: 15px;
display: flex; display: flex;

View File

@ -130,4 +130,14 @@ export const IconBillType = ({style}: IconProps) => {
</g> </g>
</svg> </svg>
) )
} }
export const IconLoading = ({size}:{size?:string|number})=>(<svg xmlns="http://www.w3.org/2000/svg" style={{
margin: 'auto', display: 'block',...(size?{fontSize:size}:{})
}} width="1em" height="1em" viewBox="0 0 100 100" preserveAspectRatio="xMidYMid">
<circle cx="50" cy="50" r="30" stroke="#6a6a6a" strokeWidth="6" fill="none"></circle>
<circle cx="50" cy="50" r="30" stroke="#aaaaaa" strokeWidth="6" strokeLinecap="round" fill="none">
<animateTransform attributeName="transform" type="rotate" repeatCount="indefinite" dur="1s" values="0 50 50;180 50 50;720 50 50" keyTimes="0;0.5;1"></animateTransform>
<animate attributeName="stroke-dasharray" repeatCount="indefinite" dur="1s" values="18.84955592153876 169.64600329384882;94.2477796076938 94.24777960769377;18.84955592153876 169.64600329384882" keyTimes="0;0.5;1"></animate>
</circle>
</svg>)

View File

@ -87,6 +87,8 @@
"bill_error": "Bills to be paid do not exist or are overdue", "bill_error": "Bills to be paid do not exist or are overdue",
"charge": "Charge", "charge": "Charge",
"confirm_pay": "CONFIRM PAYMENT", "confirm_pay": "CONFIRM PAYMENT",
"query_pay_status": "Check payment status...",
"returnBack": "Return Payment System",
"text_canceled": "Payment has been cancelled", "text_canceled": "Payment has been cancelled",
"text_failed": "Payment Failure", "text_failed": "Payment Failure",
"text_success": "Success", "text_success": "Success",

View File

@ -87,6 +87,8 @@
"bill_error": "待支付账单不存在或已过期", "bill_error": "待支付账单不存在或已过期",
"charge": "手续费", "charge": "手续费",
"confirm_pay": "确认支付", "confirm_pay": "确认支付",
"query_pay_status": "查询支付状态...",
"returnBack": "返回Payment System",
"text_canceled": "支付已取消", "text_canceled": "支付已取消",
"text_failed": "支付失败", "text_failed": "支付失败",
"text_success": "支付成功", "text_success": "支付成功",

View File

@ -87,6 +87,8 @@
"bill_error": "待支付帳單不存在或已過期", "bill_error": "待支付帳單不存在或已過期",
"charge": "手續費", "charge": "手續費",
"confirm_pay": "確認付款", "confirm_pay": "確認付款",
"query_pay_status": "查詢支付狀態...",
"returnBack": "返回Payment System",
"text_canceled": "付款已取消", "text_canceled": "付款已取消",
"text_failed": "付款失敗", "text_failed": "付款失敗",
"text_success": "付款成功", "text_success": "付款成功",

View File

@ -12,6 +12,13 @@ import {BillStatus} from "@/service/types.ts";
import {useDownloadReceiptPDF} from "@/service/generate-pdf.ts"; import {useDownloadReceiptPDF} from "@/service/generate-pdf.ts";
import useAuth from "@/hooks/useAuth.ts"; import useAuth from "@/hooks/useAuth.ts";
const DownloadButton = ({bill,text}: { bill: BillModel;text:string }) => {
const {loading: downloading, downloadPDF} = useDownloadReceiptPDF()
return (<Button
onClick={() => downloadPDF(bill)} size={'small'} theme={'solid'}
type={'primary'} loading={downloading}>{text}</Button>)
}
const BillQuery = () => { const BillQuery = () => {
const {user} = useAuth(); const {user} = useAuth();
const [showBill, setShowBill] = useState<BillModel>() const [showBill, setShowBill] = useState<BillModel>()
@ -31,7 +38,6 @@ const BillQuery = () => {
}); });
} }
}) })
const {loading: downloading, downloadPDF} = useDownloadReceiptPDF()
const {t} = useTranslation() const {t} = useTranslation()
const onConfirmCancel = (bill: BillModel) => { const onConfirmCancel = (bill: BillModel) => {
@ -56,10 +62,7 @@ const BillQuery = () => {
{AppMode == 'development' && <a href={`/pay?bill=${bill.id}`} target={'_blank'}></a>} {AppMode == 'development' && <a href={`/pay?bill=${bill.id}`} target={'_blank'}></a>}
</>} </>}
{ {
bill.status == BillStatus.PAID bill.status == BillStatus.PAID && <DownloadButton bill={bill} text={t('bill.download_receipt')}/>
&& <Button
onClick={() => downloadPDF(bill)} size={'small'} theme={'solid'}
type={'primary'} loading={downloading}>{t('bill.download_receipt')}</Button>
} }
</Space>) </Space>)
} }

View File

@ -1,43 +1,32 @@
import styles from "@/pages/pay/pay.module.less"; import styles from "@/pages/pay/pay.module.less";
import React, {useMemo} from "react"; import React, {useEffect, useState} from "react";
import {getAppUrl} from "@/hooks/useAppUrl.ts"; import {getAppUrl} from "@/hooks/useAppUrl.ts";
import {useTranslation} from "react-i18next"; import {useTranslation} from "react-i18next";
import {IconLoading} from "@/components/icons";
import {getFlywirePayUrl} from "@/service/api/bill.ts";
// 支付费用配置 type StartFlyWireProps = {
const PayFeeTypeConfig: { open?: boolean;
[key: string]: string bill: BillModel
} = {
'CAUTION FEE': 'items[caution_fee]', // caution_fee
'STUDENT UNION FEE': 'items[student_union_fee]', // student_union_fee
'TUITION FEE': 'items[tuition_fee]', // tuition_fee
'CREDIT POINT': 'items[credit_point]', // credit_point
'APPLICATION FEE': 'items[application_fee]', // application_fee
} }
function getPaymentFees(bill: BillModel) { export const StartFlyWire: React.FC<StartFlyWireProps> = ({bill, open}) => {
// 根据bill details的账单类型设置支付费用 const callbackUrl = getAppUrl() + "/pay/success?from=FLYWIRE&bill=" + bill.id;
return bill.details.map(it => {
return (PayFeeTypeConfig[it.bill_type] ?? 'items[other_fee]') + `=${Number(it.amount) * 100}`
})
}
export const StartFlyWire: React.FC<{ bill: BillModel }> = ({bill}) => {
const callbackUrl = getAppUrl() + "/pay/success?from=flywire";
const {t} = useTranslation() const {t} = useTranslation()
const [loading, setLoading] = useState<boolean>(false)
const redirectUrl = useMemo(() => { const [redirectUrl, setRedirectUrl] = useState<string>()
const fees = getPaymentFees(bill) // 支付费用 useEffect(() => {
// flywire 支付跳转连接 if (bill) {
return `${AppConfig.FIY_WIRE_GATEWAY}?provider=HAI&payment_destination=chuhaicollege` setLoading(true)
+ `&${fees.join('&')}` getFlywirePayUrl(bill.id, callbackUrl, t(`pay.returnBack`)).then(res => {
+ `&student_last_name=${encodeURIComponent(bill.student_english_name)}` setRedirectUrl(res.url)
+ `&student_id=${encodeURIComponent(bill.student_number || bill.application_number || '')}` }).catch(console.error).finally(() => setLoading(false))
+ `&student_email=${encodeURIComponent(bill.student_email)}` }
+ `&callback_url=${encodeURIComponent(callbackUrl)}`
+ `&callback_id=${encodeURIComponent(bill.merchant_ref || '')}`
}, [bill]) }, [bill])
return (<a href={redirectUrl} className={styles.btnConfirm}> return (<>
{t('pay.confirm_pay')} {open ? (!loading ? <a href={redirectUrl} className={styles.btnConfirm}>
</a>) {t('pay.confirm_pay')}
</a> : <button className={styles.btnConfirm} style={{fontSize: 26}}><IconLoading/></button>):<></>}
</>)
} }

View File

@ -93,7 +93,8 @@ const PayIndex = () => {
<div className={styles.payConfirm}> <div className={styles.payConfirm}>
<div className="student-email">Your Email: {bill.student_email}</div> <div className="student-email">Your Email: {bill.student_email}</div>
<div className="pay-submit"> <div className="pay-submit">
{ payChannel == 'asia_pay' ? <StartAsiaPay bill={bill} /> : <StartFlyWire bill={bill} />} { payChannel == 'asia_pay' && <StartAsiaPay bill={bill} />}
<StartFlyWire bill={bill} open={payChannel == 'flywire'} />
</div> </div>
</div> </div>
</> : <></>} </> : <></>}

View File

@ -9,7 +9,14 @@
width: 600px; width: 600px;
transition: width 0.1s; transition: width 0.1s;
} }
.payLoading{
padding: 50px 0;
text-align: center;
}
.loadingText{
font-size: 32px;
margin-top: 20px;
}
.payAmount { .payAmount {
color: #fff; color: #fff;
background-color: #50ADA7; background-color: #50ADA7;

View File

@ -9,11 +9,14 @@ import {useDownloadReceiptPDF} from "@/service/generate-pdf.ts";
import {useEffect} from "react"; import {useEffect} from "react";
import {useSetState} from "ahooks"; import {useSetState} from "ahooks";
import {FailIcon} from "@/assets/images/pay/fail.tsx"; import {FailIcon} from "@/assets/images/pay/fail.tsx";
import {updateBillPaymentSuccess} from "@/service/api/bill.ts"; import {getBillDetail, updateBillPaymentSuccess} from "@/service/api/bill.ts";
import {BillStatus} from "@/service/types.ts";
type PayResult = 'fail' | 'success' | 'cancel' | 'error' | string; import {IconLoading} from "@/components/icons";
type PayResult = 'fail' | 'success' | 'cancel' | 'error' | 'loading' | string;
const QUERY_MAX_COUNT = 50,
QUERY_DELAY = 6000;
const PayIndex = () => { const PayIndex = () => {
const {t} = useTranslation() const {t} = useTranslation()
@ -24,14 +27,48 @@ const PayIndex = () => {
id?: string; id?: string;
bill?: BillModel bill?: BillModel
status?: string | null; status?: string | null;
}>({}); loading?: boolean;
queryCount: number;
}>({
result: 'loading',
queryCount: 0
});
const param = useParams<{ result: PayResult }>(); const param = useParams<{ result: PayResult }>();
const [search] = useSearchParams(); // 参数有: from: asia_spay || flywire const [search] = useSearchParams(); // 参数有: from: asia_spay || flywire
const updateBillSuccess = (billId: number, type: string, ref: string) =>{ const updateBillSuccess = (billId: number, type: string, ref: string) => {
setState({result: 'loading'})
updateBillPaymentSuccess(billId, ref, type).then(bill => { updateBillPaymentSuccess(billId, ref, type).then(bill => {
setState({bill}) setState({
}).catch(()=>{ result: 'success',
setState({result:'fail'}) bill: bill
})
}).catch(() => {
setState({result: 'fail'})
})
}
const checkBillSuccess = (billId: number) => {
setState({
result: 'loading',
queryCount: state.queryCount + 1
})
getBillDetail(Number(billId)).then((bill) => {
// 判断bill状态
if (bill.status != BillStatus.PAID) {
if(state.queryCount > QUERY_MAX_COUNT) {
setState({result: 'fail'})
}else{
// 重试
setTimeout(()=>checkBillSuccess(billId), QUERY_DELAY);
}
return;
}
setState({
result: 'success',
bill: bill
})
}).catch(() => {
}) })
} }
useEffect(() => { useEffect(() => {
@ -47,8 +84,13 @@ const PayIndex = () => {
return; return;
} }
if (result == 'success') { if (result == 'success') {
updateBillSuccess(Number(bill), from , (from == 'ASIAPAY' ? search.get('Ref')! : search.get('callback_id')!)); if (from == 'ASIAPAY') {
updateBillSuccess(Number(bill), from, search.get('Ref')!);
} else if (from == 'FLYWIRE') {
checkBillSuccess(Number(bill));
}
} }
return;
} }
setState({result, status}) setState({result, status})
}, []); }, []);
@ -56,33 +98,42 @@ const PayIndex = () => {
return (<div className={styles.container}> return (<div className={styles.container}>
<PayLogo/> <PayLogo/>
{state.result == 'success' ? <> {state.result == 'success' ? <>
<div className={styles.paySuccess}> <div className={styles.paySuccess}>
<div className={styles.successIcon}> <div className={styles.successIcon}>
<SuccessIcon/> <SuccessIcon/>
</div>
<h2 className={`${styles.payText} pay-text`}>
{t('pay.text_success')}
</h2>
</div> </div>
<h2 className={`${styles.payText} pay-text`}> {state.bill && <div className={styles.payConfirm}>
{t('pay.text_success')} <div
</h2> className="student-email">{t('bill.bill_number')}: {state.bill.student_number || state.bill.application_number}</div>
</div> <div className="pay-submit">
{state.bill && <div className={styles.payConfirm}> <Button
<div className="student-email">{t('bill.bill_number')}: {state.bill.student_number || state.bill.application_number}</div> theme={'solid'}
<div className="pay-submit"> onClick={() => downloadPDF(state.bill!)}
<Button style={{width: 250, marginTop: 20}}>{t('bill.download_receipt')}</Button>
theme={'solid'} </div>
onClick={() => downloadPDF(state.bill!)} </div>}
style={{width: 250, marginTop: 20}}>{t('bill.download_receipt')}</Button> </>
</div> : (
</div>} state.result == 'loading' ? <div className={styles.payLoading}>
</> : <div className={styles.paySuccess}> <IconLoading size={70} />
<div className={styles.successIcon} style={{color: '#f05672'}}> <div className={styles.loadingText}>{t('pay.query_pay_status')}</div>
<FailIcon/> </div>
</div> : (<div className={styles.paySuccess}>
<h2 className={`${styles.payText} pay-text`}> <div className={styles.successIcon} style={{color: '#f05672'}}>
{state.result == 'fail' ? t('pay.text_failed') : ( <FailIcon/>
state.result == 'cancel' ? t('pay.text_canceled') : (t('pay.bill_error')) </div>
)} <h2 className={`${styles.payText} pay-text`}>
</h2> {state.result == 'fail' ? t('pay.text_failed') : (
</div>} state.result == 'cancel' ? t('pay.text_canceled') : (t('pay.bill_error'))
)}
</h2>
</div>)
)
}
</div>); </div>);
} }
export default PayIndex; export default PayIndex;

View File

@ -42,6 +42,10 @@ export function getAsiaPayData(id: number) {
return get<AsiaPayModel>(`/bills/${id}/asiapay`) return get<AsiaPayModel>(`/bills/${id}/asiapay`)
} }
export function getFlywirePayUrl(id: number, return_cta: string, return_cta_name: string) {
return post<{ url: string }>(`/bills/${id}/flywire`, {return_cta, return_cta_name})
}
// 作废订单 // 作废订单
export function cancelBill(id: number) { export function cancelBill(id: number) {
return put(`/bills/${id}/cancel`) return put(`/bills/${id}/cancel`)
@ -51,6 +55,6 @@ export function confirmBills(bill_ids: number[]) {
return post(`/bills/apply`, {bill_ids}) return post(`/bills/apply`, {bill_ids})
} }
export function updateBillPaymentSuccess(bill_id: number, merchant_ref: string,payment_channel:string) { export function updateBillPaymentSuccess(bill_id: number, merchant_ref: string, payment_channel: string) {
return post<BillModel>(`/bills/finish`, {merchant_ref,payment_channel,bill_id}) return post<BillModel>(`/bills/finish`, {merchant_ref, payment_channel, bill_id})
} }

View File

@ -35,7 +35,7 @@ export function GeneratePdf(bill: BillModel) {
title: 'Programme:', title: 'Programme:',
content: bill.programme_english_name content: bill.programme_english_name
}, 56) }, 56)
drawItem(doc, {title: 'Mode of Study:', content: bill.attendance_mode}, 70) drawItem(doc, {title: 'Mode of Study:', content: bill.attendance_mode}, bill.programme_english_name.length > 70?70:64)
// draw table // draw table
autoTable(doc, { autoTable(doc, {
startY: 80, startY: 80,