完善了N多pay的逻辑和component

This commit is contained in:
Xu Chang 2024-05-07 21:32:39 +08:00
parent 68983bd5eb
commit fe8caf3da5
54 changed files with 1683 additions and 359 deletions

View File

@ -5,7 +5,11 @@ const checkers = [
action: 'create',
checker: (operation, context) => {
const { data } = operation;
data.creatorId = context.getCurrentUserId();
if (!data.creatorId) {
data.creatorId = context.getCurrentUserId();
}
data.paid = 0;
data.refunded = 0;
return 1;
}
}

View File

@ -11,6 +11,9 @@ const checkers = [
data.paid = 0;
data.applicationId = context.getApplicationId();
data.creatorId = context.getCurrentUserId();
if (!data.meta) {
data.meta = {};
}
},
},
{

View File

@ -58,7 +58,7 @@ export default OakComponent({
},
actions: ['deposit', 'withdraw'],
methods: {
async createDepositPay(price, channel, meta, success) {
async createDepositPay(price, channel, meta) {
const payId = await generateNewIdAsync();
const { oakId } = this.props;
await this.execute(undefined, undefined, undefined, [

7
es/components/order/pay/index.d.ts vendored Normal file
View File

@ -0,0 +1,7 @@
import { EntityDict } from "../../../oak-app-domain";
declare const _default: (props: import("oak-frontend-base").ReactComponentProps<EntityDict, "order", false, {
accountId: string;
accountAvailMax: number;
onPayReady: (operation: EntityDict['order']['Update'], payId: string) => void;
}>) => React.ReactElement;
export default _default;

View File

@ -0,0 +1,114 @@
import { PAY_CHANNEL_ACCOUNT_NAME } from "../../../types/PayConfig";
import { generateNewId } from "oak-domain/lib/utils/uuid";
export default OakComponent({
entity: 'order',
projection: {
id: 1,
price: 1,
paid: 1,
iState: 1,
pay$order: {
$entity: 'pay',
data: {
id: 1,
price: 1,
paid: 1,
iState: 1,
},
filter: {
iState: 'paying',
},
},
},
isList: false,
properties: {
accountId: '', // 是否可以使用帐户中的余额抵扣
accountAvailMax: 0, // 本次交易可以使用的帐户中的Avail max值调用者自己保证此数值的一致性不要扣成负数
onPayReady: (operation, payId) => undefined,
},
formData({ data }) {
const payConfig = this.features.pay.getPayConfigs();
const accountConfig = payConfig?.find(ele => ele.channel === PAY_CHANNEL_ACCOUNT_NAME);
const payConfig2 = payConfig?.filter(ele => ele !== accountConfig);
const activePay = data && data.pay$order?.[0];
const { accountPrice } = this.state;
return {
order: data,
activePay,
accountConfig,
payConfig: payConfig2,
rest: data ? data.price - data.paid - accountPrice : 0,
legal: !!(data?.['#oakLegalActions']?.includes('startPaying'))
};
},
features: ['application'],
data: {
useAccount: false,
accountPrice: 0,
channel: '',
meta: undefined,
},
methods: {
setUseAccount(v) {
this.setState({ useAccount: v }, () => this.tryCreatePay());
},
setAccountPrice(price) {
const { order } = this.state;
this.setState({ accountPrice: price, rest: order.price - order.paid - price }, () => this.tryCreatePay());
},
onPickChannel(channel) {
this.setState({
channel,
}, () => this.tryCreatePay());
},
onSetChannelMeta(meta) {
this.setState({
meta,
}, () => this.tryCreatePay());
},
tryCreatePay() {
const { oakId, accountId } = this.props;
const { useAccount, accountPrice, channel, meta, order } = this.state;
const pays = [];
let rest = order.price - order.paid;
let payId = '';
if (useAccount && accountPrice) {
pays.push({
id: generateNewId(),
channel: PAY_CHANNEL_ACCOUNT_NAME,
price: accountPrice,
accountId,
});
rest = rest - accountPrice;
}
if (rest && channel) {
payId = generateNewId();
pays.push({
id: payId,
channel,
meta,
price: rest,
});
rest = 0;
}
const { onPayReady } = this.props;
if (rest === 0) {
onPayReady({
id: generateNewId(),
action: 'startPaying',
data: {
pay$order: pays.map(ele => ({
id: generateNewId(),
action: 'create',
data: ele,
})),
},
filter: {
id: oakId,
},
}, payId);
}
}
},
actions: ['startPaying'],
});

View File

@ -0,0 +1 @@
{}

View File

@ -0,0 +1,8 @@
{
"price": "订单金额",
"choose": "请选择支付方式(%{price}元)",
"useAccount": "使用余额抵扣",
"accountMax": "当前可用余额为%{max}元",
"illegalState": "订单的当前状态【%{state}】无法开始支付",
"paying": "有一个正在支付的订单"
}

23
es/components/order/pay/web.pc.d.ts vendored Normal file
View File

@ -0,0 +1,23 @@
import React from 'react';
import { WebComponentProps } from 'oak-frontend-base';
import { EntityDict } from '../../../oak-app-domain';
import { AccountPayConfig, PayConfig } from '../../../types/PayConfig';
export default function Render(props: WebComponentProps<EntityDict, 'order', false, {
accountId?: string;
accountAvailMax: number;
order: EntityDict['order']['OpSchema'];
activePay?: EntityDict['pay']['OpSchema'];
accountConfig?: AccountPayConfig;
payConfig?: PayConfig;
accountPrice: number;
channel?: string;
meta?: object;
useAccount: boolean;
rest: number;
legal: false;
}, {
setAccountPrice: (price: number) => void;
onPickChannel: (channel: string) => void;
onSetChannelMeta: (meta?: object) => void;
setUseAccount: (v: boolean) => void;
}>): React.JSX.Element | null;

View File

@ -0,0 +1,71 @@
import React from 'react';
import { ToYuan, ToCent } from 'oak-domain/lib/utils/money';
import Styles from './web.pc.module.less';
import PayChannelPicker from '../../pay/channelPicker';
import { Divider, Checkbox, InputNumber, Flex, Result } from 'antd';
function RenderPayChannel(props) {
const { price, payConfig, t, channel, meta, onPick, onSetMeta } = props;
return (<div className={Styles.pc1}>
<div className={Styles.content}>
<div>
{t('choose', { price: ToYuan(price) })}
</div>
<Divider />
<PayChannelPicker payConfig={payConfig} channel={channel} meta={meta} onPick={onPick} onSetMeta={onSetMeta}/>
</div>
</div>);
}
function RenderAccountPay(props) {
const { max, t, accountPrice, setAccountPrice, useAccount, setUseAccount, accountAvail } = props;
return (<div className={Styles.pc1}>
<div className={Styles.content}>
<Checkbox checked={useAccount} onChange={() => {
setUseAccount(!useAccount);
if (useAccount) {
setAccountPrice(0);
}
}}>
{t('useAccount')}
</Checkbox>
{useAccount && (<>
<Divider />
<Flex align='baseline'>
<InputNumber max={ToYuan(max)} addonAfter="¥" value={typeof accountPrice === 'number' ? ToYuan(accountPrice) : null} onChange={(v) => {
if (typeof v === 'number') {
setAccountPrice(Math.floor(ToCent(v)));
}
}}/>
<div className={Styles.tips}>{t('accountMax', { max: ToYuan(accountAvail) })}</div>
</Flex>
</>)}
</div>
</div>);
}
export default function Render(props) {
const { accountId, accountAvailMax, legal, accountPrice, useAccount, order, activePay, payConfig, channel, meta, rest } = props.data;
const { t, setAccountPrice, onPickChannel, onSetChannelMeta, setUseAccount } = props.methods;
if (order) {
if (activePay) {
return (<Result status="warning" title={t('paying')}/>);
}
if (!legal) {
return (<Result status="warning" title={t('illegalState', { state: t(`order:v.iState.${order.iState}`) })}/>);
}
return (<div className={Styles.container}>
<div className={Styles.info}>
<div className={Styles.should}>{t('price')}</div>
<div className={Styles.price}>
<div>{t('common::pay.symbol')}</div>
<div>{ToYuan(order.price)}</div>
</div>
</div>
{accountId && accountAvailMax && <div className={Styles.ctrl}>
<RenderAccountPay max={Math.min(accountAvailMax, rest + accountPrice)} t={t} setAccountPrice={setAccountPrice} useAccount={useAccount} setUseAccount={setUseAccount} accountPrice={accountPrice} accountAvail={accountAvailMax}/>
</div>}
{!!(rest && rest > 0) && <div className={Styles.ctrl}>
<RenderPayChannel payConfig={payConfig} price={rest} t={t} channel={channel} meta={meta} onPick={onPickChannel} onSetMeta={onSetChannelMeta}/>
</div>}
</div>);
}
return null;
}

View File

@ -0,0 +1,60 @@
.container {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: stretch;
background-color: var(--oak-bg-color-page);
// min-height: 500px;
.info {
margin: 8px;
height: 140px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background-color: var(--oak-color-info);
.should {
font-size: large;
font-weight: bold;
}
.price {
display: flex;
flex-direction: row;
padding: 3px;
font-size: xx-large;
font-weight: bolder;
color: var(--oak-color-primary)
}
}
.ctrl {
margin: 8px;
margin-top: 10px;
flex: 1;
.pc1 {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
margin-top: 4px;
.content {
background-color: var(--oak-bg-color-container);
padding: 4px;
border-radius: 2px;
padding: 16px;
.tips {
color: var(--oak-color-warning);
font-size: small;
margin-left: 10px;
}
}
}
}
}

View File

@ -4,7 +4,7 @@ import { PayConfig } from "../../../types/PayConfig";
*
* by Xc 20240426
*/
type ExtraPicker = {
export type ExtraPicker = {
label: string;
name: string;
icon: React.ForwardRefExoticComponent<any>;

View File

@ -1,4 +1,4 @@
import { PAY_CHANNEL_OFFLINE_NAME } from '../types/PayConfig';
import { PAY_CHANNEL_ACCOUNT_NAME, PAY_CHANNEL_OFFLINE_NAME } from '../types/PayConfig';
const attrUpdateMatrix = {
pay: {
meta: {
@ -13,6 +13,15 @@ const attrUpdateMatrix = {
}
]
}
},
accountOper$entity: {
actions: ['succeedPaying'],
filter: {
accountId: {
$exists: true,
},
channel: PAY_CHANNEL_ACCOUNT_NAME,
}
}
}
};

View File

@ -45,6 +45,21 @@ const i18ns = [
position: "src/components/accountOper/list",
data: {}
},
{
id: "d39ddaee363037f4557bad511722e250",
namespace: "oak-pay-business-c-order-pay",
language: "zh-CN",
module: "oak-pay-business",
position: "src/components/order/pay",
data: {
"price": "订单金额",
"choose": "请选择支付方式(%{price}元)",
"useAccount": "使用余额抵扣",
"accountMax": "当前可用余额为%{max}元",
"illegalState": "订单的当前状态【%{state}】无法开始支付",
"paying": "有一个正在支付的订单"
}
},
{
id: "3500cc465492fca3797b75c9c0dbf517",
namespace: "oak-pay-business-c-pay-channelPicker",

View File

@ -1,7 +1,7 @@
;
export const IActionDef = {
stm: {
startPaying: ['unpaid', 'paying'],
startPaying: [['unpaid', 'partiallyPaid'], 'paying'],
payAll: [['unpaid', 'paying', 'partiallyPaid'], 'paid'],
payPartially: [['unpaid', 'paying'], 'partiallyPaid'],
payNone: ['paying', 'unpaid'],

View File

@ -5,5 +5,5 @@ export default class Pay extends Feature {
private application;
constructor(application: GeneralFeatures<EntityDict>['application']);
getPayChannels(): string[];
getPayConfigs(): (import("../types/PayConfig").AccountPayConfig | import("../types/PayConfig").WechatPayConfig | import("../types/PayConfig").OfflinePayConfig)[];
getPayConfigs(): (import("../types/PayConfig").WechatPayConfig | import("../types/PayConfig").AccountPayConfig | import("../types/PayConfig").OfflinePayConfig)[];
}

View File

@ -1,6 +1,6 @@
export const IActionDef = {
stm: {
startPaying: ['unpaid', 'paying'],
startPaying: [['unpaid', 'partiallyPaid'], 'paying'],
payAll: [['unpaid', 'paying', 'partiallyPaid'], 'paid'],
payPartially: [['unpaid', 'paying'], 'partiallyPaid'],
payNone: ['paying', 'unpaid'],

View File

@ -50,91 +50,95 @@ async function changeOrderStateByPay(filter, context, option) {
payRefunded += refunded;
}
}
if (hasPaying) {
// 一定是在支付状态
assert(!hasRefunding && payRefunded === 0 && payPaid < orderPrice);
if (orderIState !== 'paying' || orderPaid !== payPaid) {
await context.operate('order', {
id: await generateNewIdAsync(),
action: 'startPaying',
data: {
paid: payPaid,
},
filter: {
id: orderId,
},
}, option);
return 1;
const closeFn = context.openRootMode();
try {
if (hasPaying) {
// 一定是在支付状态
assert(!hasRefunding && payRefunded === 0 && payPaid < orderPrice);
if (orderIState !== 'paying' || orderPaid !== payPaid) {
await context.operate('order', {
id: await generateNewIdAsync(),
action: 'startPaying',
data: {
paid: payPaid,
},
filter: {
id: orderId,
},
}, option);
}
}
else if (hasRefunding) {
assert(!hasPaying && payPaid === orderPrice && payPaid === orderPaid && payRefunded < orderPrice);
if (orderIState !== 'refunding' || orderRefunded !== payRefunded) {
await context.operate('order', {
id: await generateNewIdAsync(),
action: 'startRefunding',
data: {
refunded: payRefunded,
},
filter: {
id: orderId,
},
}, option);
}
}
else if (payRefunded) {
// 已经在退款流程当中
assert(payPaid === orderPrice && payPaid === orderPaid);
const iState = payPaid === orderPrice ? 'refunded' : 'partiallyRefunded';
if (orderIState !== iState || orderRefunded !== payRefunded) {
const action = payPaid === orderPrice ? 'refundAll' : 'refundPartially';
await context.operate('order', {
id: await generateNewIdAsync(),
action,
data: {
refunded: payRefunded,
},
filter: {
id: orderId,
},
}, option);
}
}
else if (payPaid) {
// 在支付流程当中
assert(orderRefunded === 0);
const iState = payPaid === orderPrice ? 'paid' : 'partiallyPaid';
if (orderIState !== iState || orderPaid !== payPaid) {
const action = payPaid === orderPrice ? 'payAll' : 'payPartially';
await context.operate('order', {
id: await generateNewIdAsync(),
action,
data: {
paid: payPaid,
},
filter: {
id: orderId,
},
}, option);
}
}
else {
const iState = 'unpaid';
assert(orderRefunded === 0 && orderPaid === 0);
if (orderIState !== iState) {
await context.operate('order', {
id: await generateNewIdAsync(),
action: 'payNone',
data: {},
filter: {
id: orderId,
},
}, option);
}
}
closeFn();
return 1;
}
else if (hasRefunding) {
assert(!hasPaying && payPaid === orderPrice && payPaid === orderPaid && payRefunded < orderPrice);
if (orderIState !== 'refunding' || orderRefunded !== payRefunded) {
await context.operate('order', {
id: await generateNewIdAsync(),
action: 'startRefunding',
data: {
refunded: payRefunded,
},
filter: {
id: orderId,
},
}, option);
return 1;
}
}
else if (payRefunded) {
// 已经在退款流程当中
assert(payPaid === orderPrice && payPaid === orderPaid);
const iState = payPaid === orderPrice ? 'refunded' : 'partiallyRefunded';
if (orderIState !== iState || orderRefunded !== payRefunded) {
const action = payPaid === orderPrice ? 'refundAll' : 'refundPartially';
await context.operate('order', {
id: await generateNewIdAsync(),
action,
data: {
refunded: payRefunded,
},
filter: {
id: orderId,
},
}, option);
return 1;
}
}
else if (payPaid) {
// 在支付流程当中
assert(orderRefunded === 0);
const iState = payPaid === orderPrice ? 'paid' : 'partiallyPaid';
if (orderIState !== iState || orderPaid !== payPaid) {
const action = payPaid === orderPrice ? 'payAll' : 'payPartially';
await context.operate('order', {
id: await generateNewIdAsync(),
action,
data: {
paid: payPaid,
},
filter: {
id: orderId,
},
}, option);
return 1;
}
}
else {
const iState = 'unpaid';
assert(orderRefunded === 0 && orderPaid === 0);
if (orderIState !== iState) {
await context.operate('order', {
id: await generateNewIdAsync(),
action: 'payNone',
data: {},
filter: {
id: orderId,
},
}, option);
return 1;
}
catch (err) {
closeFn();
throw err;
}
}
const triggers = [
@ -147,32 +151,42 @@ const triggers = [
const { data } = operation;
assert(!(data instanceof Array));
const { accountId, price, orderId, id } = data;
if (orderId) {
/* if (orderId) {
if (accountId) {
// 使用帐户支付,直接成功并扣款
await context.operate('pay', {
id: await generateNewIdAsync(),
action: 'payAll',
data: {
accountOper$entity: [
{
id: await generateNewIdAsync(),
action: 'create',
data: {
const close = context.openRootMode();
try {
await context.operate('pay', {
id: await generateNewIdAsync(),
action: 'succeedPaying',
data: {
accountOper$entity: [
{
id: await generateNewIdAsync(),
totalPlus: -price,
availPlus: -price,
},
}
]
},
filter: {
id,
}
}, option);
action: 'create',
data: {
id: await generateNewIdAsync(),
totalPlus: -price!,
availPlus: -price!,
type: 'consume',
accountId,
},
}
]
},
filter: {
id,
}
}, option);
close();
}
catch (err: any) {
close();
throw err;
}
return 1;
}
}
} */
// 其余情况都是进入paying流程
await context.operate('pay', {
id: await generateNewIdAsync(),

View File

@ -18,9 +18,13 @@ export default class Account {
id: await generateNewIdAsync(),
totalPlus: -price,
availPlus: -price,
type: 'consume',
accountId,
},
}
];
data.meta = {};
data.paid = pay.price;
}
getState(pay) {
throw new Error("account类型的pay不应该需要查询此状态");

View File

@ -24,11 +24,12 @@ const watchers = [
fn: async (context, data) => {
const results = [];
for (const pay of data) {
const { applicationId, channel, timeoutAt } = pay;
const { applicationId, channel, timeoutAt, price } = pay;
const clazz = await getPayClazz(applicationId, channel, context);
const iState = await clazz.getState(pay);
if (iState !== pay.iState) {
let action = 'close';
const data2 = {};
switch (iState) {
case 'closed': {
// action = 'close';
@ -36,6 +37,7 @@ const watchers = [
}
case 'paid': {
action = 'succeedPaying';
data2.paid = price;
break;
}
default: {
@ -45,7 +47,7 @@ const watchers = [
const result = await context.operate('pay', {
id: await generateNewIdAsync(),
action,
data: {},
data: data2,
filter: {
id: pay.id,
}

View File

@ -7,7 +7,11 @@ const checkers = [
action: 'create',
checker: (operation, context) => {
const { data } = operation;
data.creatorId = context.getCurrentUserId();
if (!data.creatorId) {
data.creatorId = context.getCurrentUserId();
}
data.paid = 0;
data.refunded = 0;
return 1;
}
}

View File

@ -13,6 +13,9 @@ const checkers = [
data.paid = 0;
data.applicationId = context.getApplicationId();
data.creatorId = context.getCurrentUserId();
if (!data.meta) {
data.meta = {};
}
},
},
{

View File

@ -15,6 +15,15 @@ const attrUpdateMatrix = {
}
]
}
},
accountOper$entity: {
actions: ['succeedPaying'],
filter: {
accountId: {
$exists: true,
},
channel: PayConfig_1.PAY_CHANNEL_ACCOUNT_NAME,
}
}
}
};

View File

@ -47,6 +47,21 @@ const i18ns = [
position: "src/components/accountOper/list",
data: {}
},
{
id: "d39ddaee363037f4557bad511722e250",
namespace: "oak-pay-business-c-order-pay",
language: "zh-CN",
module: "oak-pay-business",
position: "src/components/order/pay",
data: {
"price": "订单金额",
"choose": "请选择支付方式(%{price}元)",
"useAccount": "使用余额抵扣",
"accountMax": "当前可用余额为%{max}元",
"illegalState": "订单的当前状态【%{state}】无法开始支付",
"paying": "有一个正在支付的订单"
}
},
{
id: "3500cc465492fca3797b75c9c0dbf517",
namespace: "oak-pay-business-c-pay-channelPicker",

View File

@ -4,7 +4,7 @@ exports.entityDesc = exports.IActionDef = void 0;
;
exports.IActionDef = {
stm: {
startPaying: ['unpaid', 'paying'],
startPaying: [['unpaid', 'partiallyPaid'], 'paying'],
payAll: [['unpaid', 'paying', 'partiallyPaid'], 'paid'],
payPartially: [['unpaid', 'paying'], 'partiallyPaid'],
payNone: ['paying', 'unpaid'],

View File

@ -3,7 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
exports.actionDefDict = exports.actions = exports.IActionDef = void 0;
exports.IActionDef = {
stm: {
startPaying: ['unpaid', 'paying'],
startPaying: [['unpaid', 'partiallyPaid'], 'paying'],
payAll: [['unpaid', 'paying', 'partiallyPaid'], 'paid'],
payPartially: [['unpaid', 'paying'], 'partiallyPaid'],
payNone: ['paying', 'unpaid'],

View File

@ -53,91 +53,95 @@ async function changeOrderStateByPay(filter, context, option) {
payRefunded += refunded;
}
}
if (hasPaying) {
// 一定是在支付状态
(0, assert_1.default)(!hasRefunding && payRefunded === 0 && payPaid < orderPrice);
if (orderIState !== 'paying' || orderPaid !== payPaid) {
await context.operate('order', {
id: await (0, uuid_1.generateNewIdAsync)(),
action: 'startPaying',
data: {
paid: payPaid,
},
filter: {
id: orderId,
},
}, option);
return 1;
const closeFn = context.openRootMode();
try {
if (hasPaying) {
// 一定是在支付状态
(0, assert_1.default)(!hasRefunding && payRefunded === 0 && payPaid < orderPrice);
if (orderIState !== 'paying' || orderPaid !== payPaid) {
await context.operate('order', {
id: await (0, uuid_1.generateNewIdAsync)(),
action: 'startPaying',
data: {
paid: payPaid,
},
filter: {
id: orderId,
},
}, option);
}
}
else if (hasRefunding) {
(0, assert_1.default)(!hasPaying && payPaid === orderPrice && payPaid === orderPaid && payRefunded < orderPrice);
if (orderIState !== 'refunding' || orderRefunded !== payRefunded) {
await context.operate('order', {
id: await (0, uuid_1.generateNewIdAsync)(),
action: 'startRefunding',
data: {
refunded: payRefunded,
},
filter: {
id: orderId,
},
}, option);
}
}
else if (payRefunded) {
// 已经在退款流程当中
(0, assert_1.default)(payPaid === orderPrice && payPaid === orderPaid);
const iState = payPaid === orderPrice ? 'refunded' : 'partiallyRefunded';
if (orderIState !== iState || orderRefunded !== payRefunded) {
const action = payPaid === orderPrice ? 'refundAll' : 'refundPartially';
await context.operate('order', {
id: await (0, uuid_1.generateNewIdAsync)(),
action,
data: {
refunded: payRefunded,
},
filter: {
id: orderId,
},
}, option);
}
}
else if (payPaid) {
// 在支付流程当中
(0, assert_1.default)(orderRefunded === 0);
const iState = payPaid === orderPrice ? 'paid' : 'partiallyPaid';
if (orderIState !== iState || orderPaid !== payPaid) {
const action = payPaid === orderPrice ? 'payAll' : 'payPartially';
await context.operate('order', {
id: await (0, uuid_1.generateNewIdAsync)(),
action,
data: {
paid: payPaid,
},
filter: {
id: orderId,
},
}, option);
}
}
else {
const iState = 'unpaid';
(0, assert_1.default)(orderRefunded === 0 && orderPaid === 0);
if (orderIState !== iState) {
await context.operate('order', {
id: await (0, uuid_1.generateNewIdAsync)(),
action: 'payNone',
data: {},
filter: {
id: orderId,
},
}, option);
}
}
closeFn();
return 1;
}
else if (hasRefunding) {
(0, assert_1.default)(!hasPaying && payPaid === orderPrice && payPaid === orderPaid && payRefunded < orderPrice);
if (orderIState !== 'refunding' || orderRefunded !== payRefunded) {
await context.operate('order', {
id: await (0, uuid_1.generateNewIdAsync)(),
action: 'startRefunding',
data: {
refunded: payRefunded,
},
filter: {
id: orderId,
},
}, option);
return 1;
}
}
else if (payRefunded) {
// 已经在退款流程当中
(0, assert_1.default)(payPaid === orderPrice && payPaid === orderPaid);
const iState = payPaid === orderPrice ? 'refunded' : 'partiallyRefunded';
if (orderIState !== iState || orderRefunded !== payRefunded) {
const action = payPaid === orderPrice ? 'refundAll' : 'refundPartially';
await context.operate('order', {
id: await (0, uuid_1.generateNewIdAsync)(),
action,
data: {
refunded: payRefunded,
},
filter: {
id: orderId,
},
}, option);
return 1;
}
}
else if (payPaid) {
// 在支付流程当中
(0, assert_1.default)(orderRefunded === 0);
const iState = payPaid === orderPrice ? 'paid' : 'partiallyPaid';
if (orderIState !== iState || orderPaid !== payPaid) {
const action = payPaid === orderPrice ? 'payAll' : 'payPartially';
await context.operate('order', {
id: await (0, uuid_1.generateNewIdAsync)(),
action,
data: {
paid: payPaid,
},
filter: {
id: orderId,
},
}, option);
return 1;
}
}
else {
const iState = 'unpaid';
(0, assert_1.default)(orderRefunded === 0 && orderPaid === 0);
if (orderIState !== iState) {
await context.operate('order', {
id: await (0, uuid_1.generateNewIdAsync)(),
action: 'payNone',
data: {},
filter: {
id: orderId,
},
}, option);
return 1;
}
catch (err) {
closeFn();
throw err;
}
}
const triggers = [
@ -150,32 +154,42 @@ const triggers = [
const { data } = operation;
(0, assert_1.default)(!(data instanceof Array));
const { accountId, price, orderId, id } = data;
if (orderId) {
/* if (orderId) {
if (accountId) {
// 使用帐户支付,直接成功并扣款
await context.operate('pay', {
id: await (0, uuid_1.generateNewIdAsync)(),
action: 'payAll',
data: {
accountOper$entity: [
{
id: await (0, uuid_1.generateNewIdAsync)(),
action: 'create',
data: {
id: await (0, uuid_1.generateNewIdAsync)(),
totalPlus: -price,
availPlus: -price,
},
}
]
},
filter: {
id,
}
}, option);
const close = context.openRootMode();
try {
await context.operate('pay', {
id: await generateNewIdAsync(),
action: 'succeedPaying',
data: {
accountOper$entity: [
{
id: await generateNewIdAsync(),
action: 'create',
data: {
id: await generateNewIdAsync(),
totalPlus: -price!,
availPlus: -price!,
type: 'consume',
accountId,
},
}
]
},
filter: {
id,
}
}, option);
close();
}
catch (err: any) {
close();
throw err;
}
return 1;
}
}
} */
// 其余情况都是进入paying流程
await context.operate('pay', {
id: await (0, uuid_1.generateNewIdAsync)(),

View File

@ -21,9 +21,13 @@ class Account {
id: await (0, uuid_1.generateNewIdAsync)(),
totalPlus: -price,
availPlus: -price,
type: 'consume',
accountId,
},
}
];
data.meta = {};
data.paid = pay.price;
}
getState(pay) {
throw new Error("account类型的pay不应该需要查询此状态");

View File

@ -27,11 +27,12 @@ const watchers = [
fn: async (context, data) => {
const results = [];
for (const pay of data) {
const { applicationId, channel, timeoutAt } = pay;
const { applicationId, channel, timeoutAt, price } = pay;
const clazz = await (0, payClazz_1.getPayClazz)(applicationId, channel, context);
const iState = await clazz.getState(pay);
if (iState !== pay.iState) {
let action = 'close';
const data2 = {};
switch (iState) {
case 'closed': {
// action = 'close';
@ -39,6 +40,7 @@ const watchers = [
}
case 'paid': {
action = 'succeedPaying';
data2.paid = price;
break;
}
default: {
@ -48,7 +50,7 @@ const watchers = [
const result = await context.operate('pay', {
id: await (0, uuid_1.generateNewIdAsync)(),
action,
data: {},
data: data2,
filter: {
id: pay.id,
}

View File

@ -9,7 +9,11 @@ const checkers: Checker<EntityDict, 'order', RuntimeCxt>[] = [
action: 'create',
checker: (operation, context) => {
const { data } = operation as EntityDict['order']['CreateSingle'];
data.creatorId = context.getCurrentUserId()!;
if (!data.creatorId) {
data.creatorId = context.getCurrentUserId()!;
}
data.paid = 0;
data.refunded = 0;
return 1;
}

View File

@ -17,6 +17,9 @@ const checkers: Checker<EntityDict, 'pay', RuntimeCxt>[] = [
data.paid = 0;
data.applicationId = context.getApplicationId()!;
data.creatorId = context.getCurrentUserId()!;
if (!data.meta) {
data.meta = {};
}
},
},
{

View File

@ -0,0 +1,14 @@
import React, { useState } from 'react';
import { Input, Radio, Space, Form, Select, Flex } from 'antd';
import { RowWithActions, WebComponentProps } from 'oak-frontend-base';
import { EntityDict } from '@project/oak-app-domain';
import Styles from './web.pc.module.less';
import classNames from 'classnames';
export default function Render(props: WebComponentProps<EntityDict, 'accountOper', false, {
accountOpers: RowWithActions<EntityDict, 'accountOper'>[];
}>) {
const { accountOpers } = props.data;
const { t } = props.methods;
return '还没有实现todo';
}

View File

@ -0,0 +1 @@
{}

View File

@ -0,0 +1,130 @@
import { EntityDict } from "@project/oak-app-domain";
import { PAY_CHANNEL_ACCOUNT_NAME } from "../../../types/PayConfig";
import { generateNewId, generateNewIdAsync } from "oak-domain/lib/utils/uuid";
import assert from 'assert';
export default OakComponent({
entity: 'order',
projection: {
id: 1,
price: 1,
paid: 1,
iState: 1,
pay$order: {
$entity: 'pay',
data: {
id: 1,
price: 1,
paid: 1,
iState: 1,
},
filter: {
iState: 'paying',
},
},
},
isList: false,
properties: {
accountId: '', // 是否可以使用帐户中的余额抵扣
accountAvailMax: 0, // 本次交易可以使用的帐户中的Avail max值调用者自己保证此数值的一致性不要扣成负数
onPayReady: (operation: EntityDict['order']['Update'], payId: string) => undefined as void,
},
formData({ data }) {
const payConfig = this.features.pay.getPayConfigs();
const accountConfig = payConfig?.find(ele => ele.channel === PAY_CHANNEL_ACCOUNT_NAME);
const payConfig2 = payConfig?.filter(
ele => ele !== accountConfig
);
const activePay = data && data.pay$order?.[0];
const { accountPrice } = this.state;
return {
order: data,
activePay,
accountConfig,
payConfig: payConfig2,
rest: data ? data.price! - data.paid! - accountPrice : 0,
legal: !!(data?.['#oakLegalActions']?.includes('startPaying'))
};
},
features: ['application'],
data: {
useAccount: false,
accountPrice: 0,
channel: '',
meta: undefined as undefined | object,
},
methods: {
setUseAccount(v: boolean) {
const { accountAvailMax } = this.props;
const { order } = this.state;
const accountMaxPrice = Math.min(accountAvailMax!, order!.price!);
this.setState({
useAccount: v,
accountPrice: accountMaxPrice,
rest: order.price! - accountMaxPrice,
}, () => this.tryCreatePay());
},
setAccountPrice(price: number) {
const { order } = this.state;
this.setState({ accountPrice: price, rest: order!.price! - order!.paid! - price }, () => this.tryCreatePay());
},
onPickChannel(channel: string) {
this.setState({
channel,
}, () => this.tryCreatePay());
},
onSetChannelMeta(meta?: object) {
this.setState({
meta,
}, () => this.tryCreatePay());
},
tryCreatePay() {
const { oakId, accountId } = this.props;
const { useAccount, accountPrice, channel, meta, order } = this.state;
const pays: Partial<EntityDict['pay']['CreateSingle']['data']>[] = [];
let rest = order!.price! - order!.paid!;
let payId = '';
if (useAccount && accountPrice) {
pays.push({
id: generateNewId(),
channel: PAY_CHANNEL_ACCOUNT_NAME,
price: accountPrice,
accountId,
});
rest = rest - accountPrice;
}
if (rest && channel) {
payId = generateNewId();
pays.push({
id: payId,
channel,
meta,
price: rest,
});
rest = 0;
}
const { onPayReady } = this.props;
if (rest === 0) {
onPayReady!({
id: generateNewId(),
action: 'startPaying',
data: {
pay$order: pays.map(
ele => ({
id: generateNewId(),
action: 'create',
data: ele,
})
),
},
filter: {
id: oakId,
},
}, payId);
}
}
},
actions: ['startPaying'],
})

View File

@ -0,0 +1,25 @@
.info {
margin: 8px;
height: 140px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background-color: var(--oak-color-info);
.should {
font-size: large;
font-weight: bold;
}
.price {
display: flex;
flex-direction: row;
padding: 3px;
font-size: xx-large;
font-weight: bolder;
color: var(--oak-color-primary);
align-items: baseline;
}
}

View File

@ -0,0 +1,14 @@
import Styles from './info.module.less';
export default function Info(props: { price: number, t: (k: string) => string }) {
const { price, t } = props;
return (
<div className={Styles.info}>
<div className={Styles.should}>{t('price')}</div>
<div className={Styles.price}>
<div>{t('common::pay.symbol')}</div>
<div>{price}</div>
</div>
</div>
);
}

View File

@ -0,0 +1,8 @@
{
"price": "订单金额",
"choose": "请选择支付方式(%{price}元)",
"useAccount": "使用余额抵扣",
"accountMax": "当前可用余额为%{max}元",
"illegalState": "订单的当前状态【%{state}】无法开始支付",
"paying": "有一个正在支付的订单"
}

View File

@ -0,0 +1,36 @@
.container {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: stretch;
background-color: var(--oak-bg-color-page);
// min-height: 500px;
.ctrl {
margin: 8px;
margin-top: 10px;
flex: 1;
.pc1 {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
margin-top: 4px;
.content {
background-color: var(--oak-bg-color-container);
padding: 4px;
border-radius: 2px;
padding: 16px;
.tips {
color: var(--oak-color-warning);
font-size: small;
margin-top: 3px;
}
}
}
}
}

View File

@ -0,0 +1,36 @@
.container {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: stretch;
background-color: var(--oak-bg-color-page);
// min-height: 500px;
.ctrl {
margin: 8px;
margin-top: 10px;
flex: 1;
.pc1 {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
margin-top: 4px;
.content {
background-color: var(--oak-bg-color-container);
padding: 4px;
border-radius: 2px;
padding: 16px;
.tips {
color: var(--oak-color-warning);
font-size: small;
margin-left: 10px;
}
}
}
}
}

View File

@ -0,0 +1,166 @@
import React, { useState } from 'react';
import { WebComponentProps } from 'oak-frontend-base';
import { ToYuan, ToCent } from 'oak-domain/lib/utils/money';
import { EntityDict } from '../../../oak-app-domain';
import { AccountPayConfig, PayConfig } from '../../../types/PayConfig';
import Styles from './web.pc.module.less';
import PayChannelPicker from '../../pay/channelPicker';
import { Divider, Checkbox, InputNumber, Flex, Result } from 'antd';
import Info from './info';
function RenderPayChannel(props: {
payConfig?: PayConfig;
price: number;
t: (k: string, params?: any) => string;
channel?: string;
meta?: object;
onPick: (channel: string) => void;
onSetMeta: (meta?: object) => void;
}) {
const { price, payConfig, t, channel, meta, onPick, onSetMeta } = props;
return (
<div className={Styles.pc1}>
<div className={Styles.content}>
<div>
{t('choose', { price: ToYuan(price) })}
</div>
<Divider />
<PayChannelPicker
payConfig={payConfig}
channel={channel}
meta={meta}
onPick={onPick}
onSetMeta={onSetMeta}
/>
</div>
</div>
);
}
function RenderAccountPay(props: {
max: number;
t: (k: string, params?: any) => string;
setAccountPrice: (price: number) => void;
useAccount: boolean;
accountPrice: number;
accountAvail: number;
setUseAccount: (v: boolean) => void;
}) {
const { max, t, accountPrice, setAccountPrice, useAccount, setUseAccount, accountAvail } = props;
return (
<div className={Styles.pc1}>
<div className={Styles.content}>
<Checkbox
checked={useAccount}
onChange={() => {
setUseAccount(!useAccount);
if (useAccount) {
setAccountPrice(0);
}
}}
>
{t('useAccount')}
</Checkbox>
{
useAccount && (
<>
<Divider />
<Flex align='baseline'>
<InputNumber
max={ToYuan(max)}
addonAfter="¥"
value={typeof accountPrice === 'number' ? ToYuan(accountPrice) : null}
onChange={(v) => {
if (typeof v === 'number') {
setAccountPrice(Math.floor(ToCent(v)));
}
}}
/>
<div className={Styles.tips}>{t('accountMax', { max: ToYuan(accountAvail) })}</div>
</Flex>
</>
)
}
</div>
</div>
)
}
export default function Render(props: WebComponentProps<EntityDict, 'order', false, {
accountId?: string;
accountAvailMax: number;
order: EntityDict['order']['OpSchema'];
activePay?: EntityDict['pay']['OpSchema'];
accountConfig?: AccountPayConfig;
payConfig?: PayConfig;
accountPrice: number;
channel?: string;
meta?: object;
useAccount: boolean;
rest: number;
legal: false;
}, {
setAccountPrice: (price: number) => void;
onPickChannel: (channel: string) => void;
onSetChannelMeta: (meta?: object) => void;
setUseAccount: (v: boolean) => void;
}>) {
const { accountId, accountAvailMax, legal, accountPrice, useAccount,
order, activePay, payConfig, channel, meta, rest } = props.data;
const { t, setAccountPrice, onPickChannel, onSetChannelMeta, setUseAccount } = props.methods;
if (order) {
if (activePay) {
return (
<Result
status="warning"
title={t('paying')}
/>
);
}
if (!legal) {
return (
<Result
status="warning"
title={t('illegalState', { state: t(`order:v.iState.${order.iState}`) })}
/>
);
}
return (
<div className={Styles.container}>
<Info
t={t}
price={ToYuan(order.price!)}
/>
{
accountId && accountAvailMax && <div className={Styles.ctrl}>
<RenderAccountPay
max={Math.min(accountAvailMax, rest + accountPrice)}
t={t}
setAccountPrice={setAccountPrice}
useAccount={useAccount}
setUseAccount={setUseAccount}
accountPrice={accountPrice}
accountAvail={accountAvailMax}
/>
</div>
}
{!!(rest && rest > 0) && <div className={Styles.ctrl}>
<RenderPayChannel
payConfig={payConfig}
price={rest}
t={t}
channel={channel}
meta={meta}
onPick={onPickChannel}
onSetMeta={onSetChannelMeta}
/>
</div>}
</div>
);
}
return null;
}

View File

@ -0,0 +1,167 @@
import React, { useState } from 'react';
import { WebComponentProps } from 'oak-frontend-base';
import { ToYuan, ToCent } from 'oak-domain/lib/utils/money';
import { EntityDict } from '../../../oak-app-domain';
import { AccountPayConfig, PayConfig } from '../../../types/PayConfig';
import Styles from './web.mobile.module.less';
import PayChannelPicker from '../../pay/channelPicker';
import { InputNumber } from 'antd';
import { Checkbox, Divider, ErrorBlock } from 'antd-mobile';
import Info from './info';
function RenderPayChannel(props: {
payConfig?: PayConfig;
price: number;
t: (k: string, params?: any) => string;
channel?: string;
meta?: object;
onPick: (channel: string) => void;
onSetMeta: (meta?: object) => void;
}) {
const { price, payConfig, t, channel, meta, onPick, onSetMeta } = props;
return (
<div className={Styles.pc1}>
<div className={Styles.content}>
<div>
{t('choose', { price: ToYuan(price) })}
</div>
<Divider />
<PayChannelPicker
payConfig={payConfig}
channel={channel}
meta={meta}
onPick={onPick}
onSetMeta={onSetMeta}
/>
</div>
</div>
);
}
function RenderAccountPay(props: {
max: number;
t: (k: string, params?: any) => string;
setAccountPrice: (price: number) => void;
useAccount: boolean;
accountPrice: number;
accountAvail: number;
setUseAccount: (v: boolean) => void;
}) {
const { max, t, accountPrice, setAccountPrice, useAccount, setUseAccount, accountAvail } = props;
return (
<div className={Styles.pc1}>
<div className={Styles.content}>
<Checkbox
checked={useAccount}
onChange={() => {
setUseAccount(!useAccount);
if (useAccount) {
setAccountPrice(0);
}
}}
>
{t('useAccount')}
</Checkbox>
{
useAccount && (
<>
<Divider />
<div>
<InputNumber
max={ToYuan(max)}
addonAfter="¥"
value={typeof accountPrice === 'number' ? ToYuan(accountPrice) : null}
onChange={(v) => {
if (typeof v === 'number') {
setAccountPrice(Math.floor(ToCent(v)));
}
}}
/>
<div className={Styles.tips}>{t('accountMax', { max: ToYuan(accountAvail) })}</div>
</div>
</>
)
}
</div>
</div>
)
}
export default function Render(props: WebComponentProps<EntityDict, 'order', false, {
accountId?: string;
accountAvailMax: number;
order: EntityDict['order']['OpSchema'];
activePay?: EntityDict['pay']['OpSchema'];
accountConfig?: AccountPayConfig;
payConfig?: PayConfig;
accountPrice: number;
channel?: string;
meta?: object;
useAccount: boolean;
rest: number;
legal: false;
}, {
setAccountPrice: (price: number) => void;
onPickChannel: (channel: string) => void;
onSetChannelMeta: (meta?: object) => void;
setUseAccount: (v: boolean) => void;
}>) {
const { accountId, accountAvailMax, legal, accountPrice, useAccount,
order, activePay, payConfig, channel, meta, rest } = props.data;
const { t, setAccountPrice, onPickChannel, onSetChannelMeta, setUseAccount } = props.methods;
if (order) {
if (activePay) {
return (
<ErrorBlock
status="default"
title={t('paying')}
/>
);
}
if (!legal) {
return (
<ErrorBlock
status="default"
title={t('illegalState', { state: t(`order:v.iState.${order.iState}`) })}
/>
);
}
return (
<div className={Styles.container}>
<Info
t={t}
price={ToYuan(order.price!)}
/>
{
accountId && accountAvailMax && <div className={Styles.ctrl}>
<RenderAccountPay
max={Math.min(accountAvailMax, rest + accountPrice)}
t={t}
setAccountPrice={setAccountPrice}
useAccount={useAccount}
setUseAccount={setUseAccount}
accountPrice={accountPrice}
accountAvail={accountAvailMax}
/>
</div>
}
{!!(rest && rest > 0) && <div className={Styles.ctrl}>
<RenderPayChannel
payConfig={payConfig}
price={rest}
t={t}
channel={channel}
meta={meta}
onPick={onPickChannel}
onSetMeta={onSetChannelMeta}
/>
</div>}
</div>
);
}
return null;
}

View File

@ -5,7 +5,7 @@ import assert from 'assert';
*
* by Xc 20240426
*/
type ExtraPicker = {
export type ExtraPicker = {
label: string;
name: string;
icon: React.ForwardRefExoticComponent<any>;

View File

@ -1,3 +1,8 @@
.span {
margin-right: 10px;
}
.radio {
height: 40px;
}

View File

@ -78,6 +78,7 @@ export default function Render(props: WebComponentProps<EntityDict, keyof Entity
payConfig.map(
(v) => (
<Radio
className={Styles.radio}
value={v.channel}
key={v.channel}
>

View File

@ -40,6 +40,7 @@ export default OakComponent({
application,
iStateColor,
payConfig,
closable: !!(data?.["#oakLegalActions"]?.includes('close')),
};
},
features: ['application'],

View File

@ -0,0 +1,35 @@
.container {
display: flex;
align-items: stretch;
flex-direction: column;
height: 100%;
}
.meta {
margin-top: 40px;
display: flex;
flex-direction: column;
align-items: center;
.qrCodeTips {
margin-top: 28px;
}
.counter {
font-size: var(--oak-font-size-headline-medium);
font-weight: bolder;
}
}
.padding {
flex: 1;
}
.btn {
display: flex;
flex-direction: row;
.btnItem {
flex: 1;
}
}

View File

@ -4,7 +4,6 @@ import { RowWithActions, WebComponentProps } from 'oak-frontend-base';
import { EntityDict } from '@project/oak-app-domain';
import { CentToString } from 'oak-domain/lib/utils/money';
import Styles from './web.pc.module.less';
import classNames from 'classnames';
import * as dayJs from 'dayjs';
import duration from 'dayjs/plugin/duration';
dayJs.extend(duration);
@ -14,7 +13,6 @@ import {
PAY_CHANNEL_WECHAT_H5_NAME, PAY_CHANNEL_WECHAT_JS_NAME, PAY_CHANNEL_WECHAT_MP_NAME,
PAY_CHANNEL_WECHAT_NATIVE_NAME, PayConfig
} from '../../../types/PayConfig';
import { WechatOutlined, MoneyCollectOutlined, WalletOutlined } from '@ant-design/icons';
export function RenderOffline(props: {
pay: RowWithActions<EntityDict, 'pay'>,
@ -34,7 +32,7 @@ export function RenderOffline(props: {
style={{ width: '100%', marginTop: 12 }}
>
<Form.Item label={t("offline.label.tips")}>
<span style={{ wordBreak: 'break-all' }}>{offline.tips}</span>
<span style={{ wordBreak: 'break-all', textDecoration: 'underline' }}>{offline.tips}</span>
</Form.Item>
<Form.Item label={t("offline.label.option")}>
<Select
@ -80,7 +78,7 @@ function Counter(props: { deadline: number }) {
}
}
useEffect(() => {
timerFn();
timerFn();
}, []);
return (
@ -105,11 +103,11 @@ function RenderWechatPay(props: {
size={280}
/>
<div className={Styles.qrCodeTips}>
{
process.env.NODE_ENV === 'production' ?
<Alert type="info" message={t('wechat.native.tips')} /> :
<Alert type="warning" message={t('wechat.native.tips2')} />
}
{
process.env.NODE_ENV === 'production' ?
<Alert type="info" message={t('wechat.native.tips')} /> :
<Alert type="warning" message={t('wechat.native.tips2')} />
}
</div>
</>
);
@ -174,12 +172,12 @@ export default function Render(props: WebComponentProps<EntityDict, 'pay', false
iStateColor?: string;
payConfig?: PayConfig;
onClose: () => undefined;
closable: boolean;
}>) {
const { pay, application, iStateColor, payConfig, oakExecutable, onClose } = props.data;
const { t, update, execute } = props.methods;
const { pay, application, iStateColor, payConfig, oakExecutable, onClose, closable } = props.data;
const { t, update, execute, clean } = props.methods;
if (pay && application) {
const { iState, channel, price, '#oakLegalActions': legalActions } = pay;
const closable = !!legalActions?.find(ele => ele === 'close');
return (
<Card
@ -217,11 +215,19 @@ export default function Render(props: WebComponentProps<EntityDict, 'pay', false
<div className={Styles.btn}>
{
oakExecutable === true && (
<Button
onClick={() => execute()}
>
{t('common::action.update')}
</Button>
<>
<Button
type="primary"
onClick={() => execute()}
>
{t('common::action.update')}
</Button>
<Button
onClick={() => clean()}
>
{t('common::reset')}
</Button>
</>
)
}
{

View File

@ -1,22 +1,264 @@
import React, { useState } from 'react';
import { Input, Radio, Space, Form, Select, Flex } from 'antd';
import React, { useEffect, useState } from 'react';
import { Card, Tag, List, Button, Modal, Form, Selector, Input, TextArea } from 'antd-mobile';
import { QRCode, Alert } from 'antd';
import { RowWithActions, WebComponentProps } from 'oak-frontend-base';
import { EntityDict } from '@project/oak-app-domain';
import Styles from './web.pc.module.less';
import classNames from 'classnames';
import Styles from './web.mobile.module.less';
import * as dayJs from 'dayjs';
import { CentToString } from 'oak-domain/lib/utils/money';
import {
OfflinePayConfig,
PAY_CHANNEL_ACCOUNT_NAME, PAY_CHANNEL_OFFLINE_NAME, PAY_CHANNEL_WECHAT_APP_NAME,
PAY_CHANNEL_WECHAT_H5_NAME, PAY_CHANNEL_WECHAT_JS_NAME, PAY_CHANNEL_WECHAT_MP_NAME,
PAY_CHANNEL_WECHAT_NATIVE_NAME, PayConfig
} from '../../../types/PayConfig';
import { WechatOutlined, MoneyCollectOutlined, WalletOutlined } from '@ant-design/icons';
import { PayCircleOutline, GlobalOutline } from 'antd-mobile-icons';
export function RenderOffline(props: {
pay: RowWithActions<EntityDict, 'pay'>,
t: (key: string) => string,
offline: OfflinePayConfig,
updateMeta: (meta: any) => void,
metaUpdatable: boolean
}) {
const { pay, t, offline, updateMeta, metaUpdatable } = props;
const { meta, iState } = pay;
return (
<>
<Form
layout="horizontal"
style={{ width: '100%', marginTop: 12 }}
>
<Form.Item label={t("offline.label.tips")}>
<span style={{ wordBreak: 'break-all', textDecoration: 'underline' }}>{offline.tips}</span>
</Form.Item>
{!!(offline.options?.length) && <Form.Item label={t("offline.label.option")}>
<Selector
value={(meta as { option?: string })?.option ? [(meta as { option: string })?.option] : undefined}
options={offline.options!.map(
ele => ({
label: ele,
value: ele,
}),
)}
disabled={!metaUpdatable}
onChange={(v) => updateMeta({
...meta,
option: v[0],
})}
/>
</Form.Item>}
<Form.Item label={t("offline.label.serial")}>
<TextArea
autoSize={{ minRows: 3 }}
value={(meta as { serial?: string })?.serial}
disabled={!metaUpdatable}
placeholder={metaUpdatable ? t('offline.placeholder.serial') : t('offline.placeholder.none')}
onChange={(value) => updateMeta({
...meta,
serial: value,
})}
/>
</Form.Item>
</Form>
</>
)
}
function Counter(props: { deadline: number }) {
const { deadline } = props;
const [counter, setCounter] = useState('');
const timerFn = () => {
const now = Date.now();
if (now < deadline) {
const duration = dayJs.duration(deadline - now);
setCounter(duration.format('HH:mm:ss'));
setTimeout(timerFn, 1000);
}
}
useEffect(() => {
timerFn();
}, []);
return (
<div className={Styles.counter}>{counter}</div>
);
}
function RenderWechatPay(props: {
pay: RowWithActions<EntityDict, 'pay'>;
t: (key: string) => string;
}) {
const { pay, t } = props;
const { externalId, channel, timeoutAt, iState } = pay;
switch (channel) {
case PAY_CHANNEL_WECHAT_NATIVE_NAME: {
if (iState! === 'paying') {
return (
<>
<Counter deadline={timeoutAt as number} />
<QRCode
value={externalId!}
size={280}
/>
<div className={Styles.qrCodeTips}>
{
process.env.NODE_ENV === 'production' ?
<Alert type="info" message={t('wechat.native.tips')} /> :
<Alert type="warning" message={t('wechat.native.tips2')} />
}
</div>
</>
);
}
break;
}
}
return null;
}
function RenderPayMeta(props: {
pay: RowWithActions<EntityDict, 'pay'>,
application: EntityDict['application']['Schema'],
t: (key: string) => string,
payConfig: PayConfig,
updateMeta: (meta: any) => void,
}) {
const { pay, application, t, payConfig, updateMeta } = props;
const { iState, channel } = pay;
if (['unpaid', 'paying'].includes(iState!) && pay.applicationId !== application.id && channel !== PAY_CHANNEL_OFFLINE_NAME) {
return <Alert type='warning' message={t('notSameApp')} />
}
switch (channel) {
case PAY_CHANNEL_OFFLINE_NAME: {
const { '#oakLegalActions': legalActions } = pay;
const metaUpdatable = !!legalActions?.find(
ele => typeof ele === 'object'
&& ele.action === 'update'
&& ele.attrs?.includes('meta')
);
return (
<>
{iState === 'paying' && <Alert type='info' message={t('offline.tips')} />}
{RenderOffline(
{
pay,
t,
offline: payConfig.find(ele => ele.channel === PAY_CHANNEL_OFFLINE_NAME) as OfflinePayConfig,
updateMeta,
metaUpdatable
}
)}
</>
);
}
case PAY_CHANNEL_WECHAT_APP_NAME:
case PAY_CHANNEL_WECHAT_H5_NAME:
case PAY_CHANNEL_WECHAT_JS_NAME:
case PAY_CHANNEL_WECHAT_MP_NAME:
case PAY_CHANNEL_WECHAT_NATIVE_NAME: {
return <RenderWechatPay pay={pay} t={t} />
}
}
return null;
}
export default function Render(props: WebComponentProps<EntityDict, 'pay', false, {
pay: RowWithActions<EntityDict, 'pay'>;
application?: EntityDict['application']['Schema'];
iStateColor?: string;
payConfig?: PayConfig;
onClose: () => undefined;
closable: boolean;
}>) {
const { pay } = props.data;
const { t } = props.methods;
const { pay, application, iStateColor, payConfig, oakExecutable, onClose, closable } = props.data;
const { t, update, execute, clean } = props.methods;
if (pay && application) {
const { iState, channel, price, '#oakLegalActions': legalActions } = pay;
return (
<div className={Styles.container}>
<Card
title={t('title')}
extra={<Tag color={iStateColor}>{t(`pay:v.iState.${iState}`)}</Tag>}
>
<div>
<List>
<List.Item
prefix={<PayCircleOutline />}
extra={CentToString(price!, 2)}
>
{t('pay:attr.price')}
</List.Item>
<List.Item
prefix={<GlobalOutline />}
extra={t(`payChannel::${channel}`)}
>
{t('pay:attr.channel')}
</List.Item>
</List>
</div>
</Card>
<div className={Styles.meta}>
<RenderPayMeta
pay={pay}
t={t}
application={application}
payConfig={payConfig!}
updateMeta={(meta) => update({ meta })}
/>
</div>
<div className={Styles.padding} />
<div className={Styles.btn}>
{
oakExecutable === true && (
<>
<div className={Styles.btnItem}>
<Button
block
color='primary'
onClick={() => execute()}
>
{t('common::action.update')}
</Button>
</div>
<div className={Styles.btnItem}>
<Button
block
onClick={() => clean()}
>
{t('common::reset')}
</Button>
</div>
</>
)
}
{
closable && !(oakExecutable === true) && (
<Button
block
color="primary"
onClick={() => {
Modal.confirm({
title: t('cc.title'),
content: t('cc.content'),
onConfirm: async () => {
await execute('close');
onClose();
}
});
}}
>
{t('pay:action.close')}
</Button>
)
}
</div>
</div>
)
}
return null;
}

View File

@ -1,6 +1,6 @@
import { AttrUpdateMatrix } from 'oak-domain/lib/types/EntityDesc';
import { EntityDict } from '@project/oak-app-domain';
import { PAY_CHANNEL_OFFLINE_NAME } from '@project/types/PayConfig';
import { PAY_CHANNEL_ACCOUNT_NAME, PAY_CHANNEL_OFFLINE_NAME } from '@project/types/PayConfig';
const attrUpdateMatrix: AttrUpdateMatrix<EntityDict> = {
@ -17,6 +17,15 @@ const attrUpdateMatrix: AttrUpdateMatrix<EntityDict> = {
}
]
}
},
accountOper$entity: {
actions: ['succeedPaying'],
filter: {
accountId: {
$exists: true,
},
channel: PAY_CHANNEL_ACCOUNT_NAME,
}
}
}
};

View File

@ -47,6 +47,21 @@ const i18ns: I18n[] = [
position: "src/components/accountOper/list",
data: {}
},
{
id: "d39ddaee363037f4557bad511722e250",
namespace: "oak-pay-business-c-order-pay",
language: "zh-CN",
module: "oak-pay-business",
position: "src/components/order/pay",
data: {
"price": "订单金额",
"choose": "请选择支付方式(%{price}元)",
"useAccount": "使用余额抵扣",
"accountMax": "当前可用余额为%{max}元",
"illegalState": "订单的当前状态【%{state}】无法开始支付",
"paying": "有一个正在支付的订单"
}
},
{
id: "3500cc465492fca3797b75c9c0dbf517",
namespace: "oak-pay-business-c-pay-channelPicker",

View File

@ -26,7 +26,7 @@ export type IState = 'paid' | 'unpaid' | 'timeout' | 'cancelled' | 'paying' | 'p
export const IActionDef: ActionDef<IAction, IState> = {
stm: {
startPaying: ['unpaid', 'paying'],
startPaying: [['unpaid', 'partiallyPaid'], 'paying'],
payAll: [['unpaid', 'paying', 'partiallyPaid'], 'paid'],
payPartially: [['unpaid', 'paying'], 'partiallyPaid'],
payNone: ['paying', 'unpaid'],

View File

@ -87,7 +87,7 @@ export const entityDesc: EntityDesc<Schema, Action, '', {
zh_CN: {
name: '订单',
attr: {
price: '订单金额',
price: '应支付金额',
paid: '已支付金额',
refunded: '已退款金额',
iState: '支付状态',

View File

@ -61,92 +61,96 @@ async function changeOrderStateByPay(
}
}
if (hasPaying) {
// 一定是在支付状态
assert(!hasRefunding && payRefunded === 0 && payPaid < orderPrice!);
if (orderIState !== 'paying' || orderPaid !== payPaid) {
await context.operate('order', {
id: await generateNewIdAsync(),
action: 'startPaying',
data: {
paid: payPaid,
},
filter: {
id: orderId!,
},
}, option);
return 1;
const closeFn = context.openRootMode();
try {
if (hasPaying) {
// 一定是在支付状态
assert(!hasRefunding && payRefunded === 0 && payPaid < orderPrice!);
if (orderIState !== 'paying' || orderPaid !== payPaid) {
await context.operate('order', {
id: await generateNewIdAsync(),
action: 'startPaying',
data: {
paid: payPaid,
},
filter: {
id: orderId!,
},
}, option);
}
}
else if (hasRefunding) {
assert(!hasPaying && payPaid === orderPrice && payPaid === orderPaid && payRefunded < orderPrice);
if (orderIState !== 'refunding' || orderRefunded !== payRefunded) {
await context.operate('order', {
id: await generateNewIdAsync(),
action: 'startRefunding',
data: {
refunded: payRefunded,
},
filter: {
id: orderId!,
},
}, option);
}
}
else if (payRefunded) {
// 已经在退款流程当中
assert(payPaid === orderPrice && payPaid === orderPaid);
const iState = payPaid === orderPrice ? 'refunded' : 'partiallyRefunded';
if (orderIState !== iState || orderRefunded !== payRefunded) {
const action = payPaid === orderPrice ? 'refundAll' : 'refundPartially';
await context.operate('order', {
id: await generateNewIdAsync(),
action,
data: {
refunded: payRefunded,
},
filter: {
id: orderId!,
},
}, option);
}
}
else if (payPaid) {
// 在支付流程当中
assert(orderRefunded === 0);
const iState = payPaid === orderPrice ? 'paid' : 'partiallyPaid';
if (orderIState !== iState || orderPaid !== payPaid) {
const action = payPaid === orderPrice ? 'payAll' : 'payPartially';
await context.operate('order', {
id: await generateNewIdAsync(),
action,
data: {
paid: payPaid,
},
filter: {
id: orderId!,
},
}, option);
}
}
else {
const iState = 'unpaid';
assert(orderRefunded === 0 && orderPaid === 0);
if (orderIState !== iState) {
await context.operate('order', {
id: await generateNewIdAsync(),
action: 'payNone',
data: {
},
filter: {
id: orderId!,
},
}, option);
}
}
closeFn();
return 1;
}
else if (hasRefunding) {
assert(!hasPaying && payPaid === orderPrice && payPaid === orderPaid && payRefunded < orderPrice);
if (orderIState !== 'refunding' || orderRefunded !== payRefunded) {
await context.operate('order', {
id: await generateNewIdAsync(),
action: 'startRefunding',
data: {
refunded: payRefunded,
},
filter: {
id: orderId!,
},
}, option);
return 1;
}
}
else if (payRefunded) {
// 已经在退款流程当中
assert(payPaid === orderPrice && payPaid === orderPaid);
const iState = payPaid === orderPrice ? 'refunded' : 'partiallyRefunded';
if (orderIState !== iState || orderRefunded !== payRefunded) {
const action = payPaid === orderPrice ? 'refundAll' : 'refundPartially';
await context.operate('order', {
id: await generateNewIdAsync(),
action,
data: {
refunded: payRefunded,
},
filter: {
id: orderId!,
},
}, option);
return 1;
}
}
else if (payPaid) {
// 在支付流程当中
assert(orderRefunded === 0);
const iState = payPaid === orderPrice ? 'paid' : 'partiallyPaid';
if (orderIState !== iState || orderPaid !== payPaid) {
const action = payPaid === orderPrice ? 'payAll' : 'payPartially';
await context.operate('order', {
id: await generateNewIdAsync(),
action,
data: {
paid: payPaid,
},
filter: {
id: orderId!,
},
}, option);
return 1;
}
}
else {
const iState = 'unpaid';
assert(orderRefunded === 0 && orderPaid === 0);
if (orderIState !== iState) {
await context.operate('order', {
id: await generateNewIdAsync(),
action: 'payNone',
data: {
},
filter: {
id: orderId!,
},
}, option);
return 1;
}
catch (err: any) {
closeFn();
throw err;
}
}
@ -160,32 +164,42 @@ const triggers: Trigger<EntityDict, 'pay', BRC>[] = [
const { data } = operation;
assert(!(data instanceof Array));
const { accountId, price, orderId, id } = data;
if (orderId) {
/* if (orderId) {
if (accountId) {
// 使用帐户支付,直接成功并扣款
await context.operate('pay', {
id: await generateNewIdAsync(),
action: 'payAll',
data: {
accountOper$entity: [
{
id: await generateNewIdAsync(),
action: 'create',
data: {
const close = context.openRootMode();
try {
await context.operate('pay', {
id: await generateNewIdAsync(),
action: 'succeedPaying',
data: {
accountOper$entity: [
{
id: await generateNewIdAsync(),
totalPlus: -price!,
availPlus: -price!,
},
}
]
},
filter: {
id,
}
}, option);
action: 'create',
data: {
id: await generateNewIdAsync(),
totalPlus: -price!,
availPlus: -price!,
type: 'consume',
accountId,
},
}
]
},
filter: {
id,
}
}, option);
close();
}
catch (err: any) {
close();
throw err;
}
return 1;
}
}
} */
// 其余情况都是进入paying流程
await context.operate('pay', {

View File

@ -22,9 +22,13 @@ export default class Account implements PayClazz {
id: await generateNewIdAsync(),
totalPlus: -price!,
availPlus: -price!,
type: 'consume',
accountId,
},
}
];
data.meta = {};
data.paid = pay.price;
}
getState(pay: OpSchema): Promise<string | null | undefined> {

View File

@ -30,11 +30,12 @@ const watchers: Watcher<EntityDict, 'pay', BRC>[] = [
fn: async (context, data) => {
const results = [] as OperationResult<EntityDict>[];
for (const pay of data) {
const { applicationId, channel, timeoutAt } = pay;
const { applicationId, channel, timeoutAt, price } = pay;
const clazz = await getPayClazz(applicationId!, channel!, context);
const iState = await clazz.getState(pay as EntityDict['pay']['OpSchema']);
if (iState !== pay.iState!) {
let action: EntityDict['pay']['Action'] = 'close';
const data2: EntityDict['pay']['Update']['data'] = {};
switch (iState) {
case 'closed': {
// action = 'close';
@ -42,6 +43,7 @@ const watchers: Watcher<EntityDict, 'pay', BRC>[] = [
}
case 'paid': {
action = 'succeedPaying';
data2.paid = price!;
break;
}
default: {
@ -52,7 +54,7 @@ const watchers: Watcher<EntityDict, 'pay', BRC>[] = [
const result = await context.operate('pay', {
id: await generateNewIdAsync(),
action,
data: {},
data: data2,
filter: {
id: pay.id!,
}