Skip to content
GitLab
Menu
Projects
Groups
Snippets
Loading...
Help
Help
Support
Community forum
Keyboard shortcuts
?
Submit feedback
Sign in / Register
Toggle navigation
Menu
Open sidebar
陈曦
sub2api
Commits
e9de839d
Commit
e9de839d
authored
Apr 20, 2026
by
IanShaw027
Browse files
feat: rebuild auth identity foundation flow
parent
fbd0a2e3
Changes
123
Hide whitespace changes
Inline
Side-by-side
frontend/src/views/auth/__tests__/OidcCallbackView.spec.ts
0 → 100644
View file @
e9de839d
import
{
beforeEach
,
describe
,
expect
,
it
,
vi
}
from
'
vitest
'
import
{
flushPromises
,
mount
}
from
'
@vue/test-utils
'
import
OidcCallbackView
from
'
../OidcCallbackView.vue
'
const
replace
=
vi
.
fn
()
const
showSuccess
=
vi
.
fn
()
const
showError
=
vi
.
fn
()
const
setToken
=
vi
.
fn
()
const
exchangePendingOAuthCompletion
=
vi
.
fn
()
const
completeOIDCOAuthRegistration
=
vi
.
fn
()
const
getPublicSettings
=
vi
.
fn
()
vi
.
mock
(
'
vue-router
'
,
()
=>
({
useRoute
:
()
=>
({
query
:
{}
}),
useRouter
:
()
=>
({
replace
})
}))
vi
.
mock
(
'
vue-i18n
'
,
async
()
=>
{
const
actual
=
await
vi
.
importActual
<
typeof
import
(
'
vue-i18n
'
)
>
(
'
vue-i18n
'
)
return
{
...
actual
,
useI18n
:
()
=>
({
t
:
(
key
:
string
,
params
?:
Record
<
string
,
string
>
)
=>
{
if
(
!
params
?.
providerName
)
{
return
key
}
return
`
${
key
}
:
${
params
.
providerName
}
`
}
})
}
})
vi
.
mock
(
'
@/stores
'
,
()
=>
({
useAuthStore
:
()
=>
({
setToken
}),
useAppStore
:
()
=>
({
showSuccess
,
showError
})
}))
vi
.
mock
(
'
@/api/auth
'
,
()
=>
({
exchangePendingOAuthCompletion
:
(...
args
:
any
[])
=>
exchangePendingOAuthCompletion
(...
args
),
completeOIDCOAuthRegistration
:
(...
args
:
any
[])
=>
completeOIDCOAuthRegistration
(...
args
),
getPublicSettings
:
(...
args
:
any
[])
=>
getPublicSettings
(...
args
)
}))
describe
(
'
OidcCallbackView
'
,
()
=>
{
beforeEach
(()
=>
{
replace
.
mockReset
()
showSuccess
.
mockReset
()
showError
.
mockReset
()
setToken
.
mockReset
()
exchangePendingOAuthCompletion
.
mockReset
()
completeOIDCOAuthRegistration
.
mockReset
()
getPublicSettings
.
mockReset
()
getPublicSettings
.
mockResolvedValue
({
oidc_oauth_provider_name
:
'
ExampleID
'
})
})
it
(
'
does not send adoption decisions during the initial exchange
'
,
async
()
=>
{
exchangePendingOAuthCompletion
.
mockResolvedValue
({
access_token
:
'
access-token
'
,
refresh_token
:
'
refresh-token
'
,
expires_in
:
3600
,
redirect
:
'
/dashboard
'
,
adoption_required
:
true
})
setToken
.
mockResolvedValue
({})
mount
(
OidcCallbackView
,
{
global
:
{
stubs
:
{
AuthLayout
:
{
template
:
'
<div><slot /></div>
'
},
Icon
:
true
,
RouterLink
:
{
template
:
'
<a><slot /></a>
'
},
transition
:
false
}
}
})
await
flushPromises
()
expect
(
exchangePendingOAuthCompletion
).
toHaveBeenCalledTimes
(
1
)
expect
(
exchangePendingOAuthCompletion
).
toHaveBeenCalledWith
()
})
it
(
'
waits for explicit adoption confirmation before finishing a non-invitation login
'
,
async
()
=>
{
exchangePendingOAuthCompletion
.
mockResolvedValueOnce
({
redirect
:
'
/dashboard
'
,
adoption_required
:
true
,
suggested_display_name
:
'
OIDC Nick
'
,
suggested_avatar_url
:
'
https://cdn.example/oidc.png
'
})
.
mockResolvedValueOnce
({
access_token
:
'
access-token
'
,
refresh_token
:
'
refresh-token
'
,
expires_in
:
3600
,
redirect
:
'
/dashboard
'
})
setToken
.
mockResolvedValue
({})
const
wrapper
=
mount
(
OidcCallbackView
,
{
global
:
{
stubs
:
{
AuthLayout
:
{
template
:
'
<div><slot /></div>
'
},
Icon
:
true
,
RouterLink
:
{
template
:
'
<a><slot /></a>
'
},
transition
:
false
}
}
})
await
flushPromises
()
expect
(
wrapper
.
text
()).
toContain
(
'
OIDC Nick
'
)
expect
(
setToken
).
not
.
toHaveBeenCalled
()
expect
(
replace
).
not
.
toHaveBeenCalled
()
const
checkboxes
=
wrapper
.
findAll
(
'
input[type="checkbox"]
'
)
await
checkboxes
[
0
].
setValue
(
false
)
const
buttons
=
wrapper
.
findAll
(
'
button
'
)
expect
(
buttons
).
toHaveLength
(
1
)
await
buttons
[
0
].
trigger
(
'
click
'
)
await
flushPromises
()
expect
(
exchangePendingOAuthCompletion
).
toHaveBeenCalledTimes
(
2
)
expect
(
exchangePendingOAuthCompletion
).
toHaveBeenNthCalledWith
(
1
)
expect
(
exchangePendingOAuthCompletion
).
toHaveBeenNthCalledWith
(
2
,
{
adoptDisplayName
:
false
,
adoptAvatar
:
true
})
expect
(
setToken
).
toHaveBeenCalledWith
(
'
access-token
'
)
expect
(
replace
).
toHaveBeenCalledWith
(
'
/dashboard
'
)
})
it
(
'
renders adoption choices for invitation flow and submits the selected values
'
,
async
()
=>
{
exchangePendingOAuthCompletion
.
mockResolvedValue
({
error
:
'
invitation_required
'
,
redirect
:
'
/dashboard
'
,
adoption_required
:
true
,
suggested_display_name
:
'
OIDC Nick
'
,
suggested_avatar_url
:
'
https://cdn.example/oidc.png
'
})
completeOIDCOAuthRegistration
.
mockResolvedValue
({
access_token
:
'
access-token
'
,
refresh_token
:
'
refresh-token
'
,
expires_in
:
3600
,
token_type
:
'
Bearer
'
})
setToken
.
mockResolvedValue
({})
const
wrapper
=
mount
(
OidcCallbackView
,
{
global
:
{
stubs
:
{
AuthLayout
:
{
template
:
'
<div><slot /></div>
'
},
Icon
:
true
,
RouterLink
:
{
template
:
'
<a><slot /></a>
'
},
transition
:
false
}
}
})
await
flushPromises
()
expect
(
wrapper
.
text
()).
toContain
(
'
OIDC Nick
'
)
expect
(
exchangePendingOAuthCompletion
).
toHaveBeenCalledTimes
(
1
)
expect
(
exchangePendingOAuthCompletion
).
toHaveBeenCalledWith
()
const
checkboxes
=
wrapper
.
findAll
(
'
input[type="checkbox"]
'
)
expect
(
checkboxes
).
toHaveLength
(
2
)
await
checkboxes
[
1
].
setValue
(
false
)
await
wrapper
.
find
(
'
input[type="text"]
'
).
setValue
(
'
invite-code
'
)
await
wrapper
.
find
(
'
button
'
).
trigger
(
'
click
'
)
expect
(
completeOIDCOAuthRegistration
).
toHaveBeenCalledWith
(
'
invite-code
'
,
{
adoptDisplayName
:
true
,
adoptAvatar
:
false
})
})
})
frontend/src/views/auth/__tests__/WechatCallbackView.spec.ts
0 → 100644
View file @
e9de839d
import
{
flushPromises
,
mount
}
from
'
@vue/test-utils
'
import
{
beforeEach
,
describe
,
expect
,
it
,
vi
}
from
'
vitest
'
import
WechatCallbackView
from
'
@/views/auth/WechatCallbackView.vue
'
const
{
postMock
,
replaceMock
,
setTokenMock
,
showSuccessMock
,
showErrorMock
,
routeState
,
}
=
vi
.
hoisted
(()
=>
({
postMock
:
vi
.
fn
(),
replaceMock
:
vi
.
fn
(),
setTokenMock
:
vi
.
fn
(),
showSuccessMock
:
vi
.
fn
(),
showErrorMock
:
vi
.
fn
(),
routeState
:
{
query
:
{}
as
Record
<
string
,
unknown
>
,
},
}))
vi
.
mock
(
'
vue-router
'
,
()
=>
({
useRoute
:
()
=>
routeState
,
useRouter
:
()
=>
({
replace
:
replaceMock
,
}),
}))
vi
.
mock
(
'
vue-i18n
'
,
()
=>
({
createI18n
:
()
=>
({
global
:
{
t
:
(
key
:
string
)
=>
key
,
},
}),
useI18n
:
()
=>
({
t
:
(
key
:
string
,
params
?:
Record
<
string
,
string
>
)
=>
{
if
(
key
===
'
auth.oidc.callbackTitle
'
)
{
return
`Signing you in with
${
params
?.
providerName
??
''
}
`
.
trim
()
}
if
(
key
===
'
auth.oidc.callbackProcessing
'
)
{
return
`Completing login with
${
params
?.
providerName
??
''
}
`
.
trim
()
}
if
(
key
===
'
auth.oidc.invitationRequired
'
)
{
return
`
${
params
?.
providerName
??
''
}
invitation required`
.
trim
()
}
if
(
key
===
'
auth.oidc.completeRegistration
'
)
{
return
'
Complete registration
'
}
if
(
key
===
'
auth.oidc.completing
'
)
{
return
'
Completing
'
}
if
(
key
===
'
auth.oidc.backToLogin
'
)
{
return
'
Back to login
'
}
if
(
key
===
'
auth.invitationCodePlaceholder
'
)
{
return
'
Invitation code
'
}
if
(
key
===
'
auth.loginSuccess
'
)
{
return
'
Login success
'
}
if
(
key
===
'
auth.loginFailed
'
)
{
return
'
Login failed
'
}
if
(
key
===
'
auth.oidc.callbackHint
'
)
{
return
'
Callback hint
'
}
if
(
key
===
'
auth.oidc.callbackMissingToken
'
)
{
return
'
Missing login token
'
}
if
(
key
===
'
auth.oidc.completeRegistrationFailed
'
)
{
return
'
Complete registration failed
'
}
return
key
},
}),
}))
vi
.
mock
(
'
@/stores
'
,
()
=>
({
useAuthStore
:
()
=>
({
setToken
:
setTokenMock
,
}),
useAppStore
:
()
=>
({
showSuccess
:
showSuccessMock
,
showError
:
showErrorMock
,
}),
}))
vi
.
mock
(
'
@/api/client
'
,
()
=>
({
apiClient
:
{
post
:
postMock
,
},
}))
describe
(
'
WechatCallbackView
'
,
()
=>
{
beforeEach
(()
=>
{
postMock
.
mockReset
()
replaceMock
.
mockReset
()
setTokenMock
.
mockReset
()
showSuccessMock
.
mockReset
()
showErrorMock
.
mockReset
()
routeState
.
query
=
{}
localStorage
.
clear
()
})
it
(
'
does not send adoption decisions during the initial exchange
'
,
async
()
=>
{
postMock
.
mockResolvedValueOnce
({
data
:
{
access_token
:
'
access-token
'
,
refresh_token
:
'
refresh-token
'
,
expires_in
:
3600
,
redirect
:
'
/dashboard
'
,
adoption_required
:
true
,
},
})
setTokenMock
.
mockResolvedValue
({})
mount
(
WechatCallbackView
,
{
global
:
{
stubs
:
{
AuthLayout
:
{
template
:
'
<div><slot /></div>
'
},
Icon
:
true
,
RouterLink
:
{
template
:
'
<a><slot /></a>
'
},
transition
:
false
,
},
},
})
await
flushPromises
()
expect
(
postMock
).
toHaveBeenCalledWith
(
'
/auth/oauth/pending/exchange
'
,
{})
expect
(
postMock
).
toHaveBeenCalledTimes
(
1
)
})
it
(
'
waits for explicit adoption confirmation before finishing a non-invitation login
'
,
async
()
=>
{
postMock
.
mockResolvedValueOnce
({
data
:
{
redirect
:
'
/dashboard
'
,
adoption_required
:
true
,
suggested_display_name
:
'
WeChat Nick
'
,
suggested_avatar_url
:
'
https://cdn.example/wechat.png
'
,
},
})
.
mockResolvedValueOnce
({
data
:
{
access_token
:
'
wechat-access-token
'
,
refresh_token
:
'
wechat-refresh-token
'
,
expires_in
:
3600
,
token_type
:
'
Bearer
'
,
redirect
:
'
/dashboard
'
,
},
})
setTokenMock
.
mockResolvedValue
({})
const
wrapper
=
mount
(
WechatCallbackView
,
{
global
:
{
stubs
:
{
AuthLayout
:
{
template
:
'
<div><slot /></div>
'
},
Icon
:
true
,
RouterLink
:
{
template
:
'
<a><slot /></a>
'
},
transition
:
false
,
},
},
})
await
flushPromises
()
expect
(
wrapper
.
text
()).
toContain
(
'
WeChat Nick
'
)
expect
(
setTokenMock
).
not
.
toHaveBeenCalled
()
expect
(
replaceMock
).
not
.
toHaveBeenCalled
()
const
checkboxes
=
wrapper
.
findAll
(
'
input[type="checkbox"]
'
)
expect
(
checkboxes
).
toHaveLength
(
2
)
await
checkboxes
[
1
].
setValue
(
false
)
const
buttons
=
wrapper
.
findAll
(
'
button
'
)
expect
(
buttons
).
toHaveLength
(
1
)
await
buttons
[
0
].
trigger
(
'
click
'
)
await
flushPromises
()
expect
(
postMock
).
toHaveBeenNthCalledWith
(
1
,
'
/auth/oauth/pending/exchange
'
,
{})
expect
(
postMock
).
toHaveBeenNthCalledWith
(
2
,
'
/auth/oauth/pending/exchange
'
,
{
adopt_display_name
:
true
,
adopt_avatar
:
false
,
})
expect
(
setTokenMock
).
toHaveBeenCalledWith
(
'
wechat-access-token
'
)
expect
(
replaceMock
).
toHaveBeenCalledWith
(
'
/dashboard
'
)
expect
(
localStorage
.
getItem
(
'
refresh_token
'
)).
toBe
(
'
wechat-refresh-token
'
)
})
it
(
'
renders adoption choices for invitation flow and submits the selected values
'
,
async
()
=>
{
postMock
.
mockResolvedValueOnce
({
data
:
{
error
:
'
invitation_required
'
,
redirect
:
'
/subscriptions
'
,
adoption_required
:
true
,
suggested_display_name
:
'
WeChat Nick
'
,
suggested_avatar_url
:
'
https://cdn.example/wechat.png
'
,
},
})
.
mockResolvedValueOnce
({
data
:
{
access_token
:
'
wechat-invite-token
'
,
refresh_token
:
'
wechat-invite-refresh
'
,
expires_in
:
600
,
token_type
:
'
Bearer
'
,
},
})
const
wrapper
=
mount
(
WechatCallbackView
,
{
global
:
{
stubs
:
{
AuthLayout
:
{
template
:
'
<div><slot /></div>
'
},
Icon
:
true
,
RouterLink
:
{
template
:
'
<a><slot /></a>
'
},
transition
:
false
,
},
},
})
await
flushPromises
()
expect
(
wrapper
.
text
()).
toContain
(
'
WeChat Nick
'
)
const
checkboxes
=
wrapper
.
findAll
(
'
input[type="checkbox"]
'
)
expect
(
checkboxes
).
toHaveLength
(
2
)
await
checkboxes
[
0
].
setValue
(
false
)
await
wrapper
.
get
(
'
input[type="text"]
'
).
setValue
(
'
INVITE-CODE
'
)
await
wrapper
.
get
(
'
button
'
).
trigger
(
'
click
'
)
await
flushPromises
()
expect
(
postMock
).
toHaveBeenNthCalledWith
(
2
,
'
/auth/oauth/wechat/complete-registration
'
,
{
invitation_code
:
'
INVITE-CODE
'
,
adopt_display_name
:
false
,
adopt_avatar
:
true
,
})
expect
(
setTokenMock
).
toHaveBeenCalledWith
(
'
wechat-invite-token
'
)
expect
(
replaceMock
).
toHaveBeenCalledWith
(
'
/subscriptions
'
)
})
})
frontend/src/views/user/PaymentView.vue
View file @
e9de839d
...
@@ -23,20 +23,7 @@
...
@@ -23,20 +23,7 @@
:order-type=
"paymentState.orderType"
:order-type=
"paymentState.orderType"
@
done=
"onPaymentDone"
@
done=
"onPaymentDone"
@
success=
"onPaymentSuccess"
@
success=
"onPaymentSuccess"
/>
@
settled=
"onPaymentSettled"
</
template
>
<
template
v-else-if=
"paymentPhase === 'stripe'"
>
<StripePaymentInline
:order-id=
"paymentState.orderId"
:amount=
"paymentState.amount"
:client-secret=
"paymentState.clientSecret"
:order-type=
"paymentState.orderType || undefined"
:publishable-key=
"checkout.stripe_publishable_key"
:pay-amount=
"paymentState.payAmount"
@
success=
"onPaymentSuccess"
@
done=
"onStripeDone"
@
back=
"resetPayment"
@
redirect=
"onStripeRedirect"
/>
/>
</
template
>
</
template
>
<!-- Tab content (select phase) -->
<!-- Tab content (select phase) -->
...
@@ -265,7 +252,7 @@
...
@@ -265,7 +252,7 @@
<
script
setup
lang
=
"
ts
"
>
<
script
setup
lang
=
"
ts
"
>
import
{
ref
,
computed
,
onMounted
,
watch
}
from
'
vue
'
import
{
ref
,
computed
,
onMounted
,
watch
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
useRoute
}
from
'
vue-router
'
import
{
useRoute
,
useRouter
}
from
'
vue-router
'
import
{
useAuthStore
}
from
'
@/stores/auth
'
import
{
useAuthStore
}
from
'
@/stores/auth
'
import
{
usePaymentStore
}
from
'
@/stores/payment
'
import
{
usePaymentStore
}
from
'
@/stores/payment
'
import
{
useSubscriptionStore
}
from
'
@/stores/subscriptions
'
import
{
useSubscriptionStore
}
from
'
@/stores/subscriptions
'
...
@@ -273,20 +260,30 @@ import { useAppStore } from '@/stores'
...
@@ -273,20 +260,30 @@ import { useAppStore } from '@/stores'
import
{
paymentAPI
}
from
'
@/api/payment
'
import
{
paymentAPI
}
from
'
@/api/payment
'
import
{
extractApiErrorMessage
}
from
'
@/utils/apiError
'
import
{
extractApiErrorMessage
}
from
'
@/utils/apiError
'
import
{
isMobileDevice
}
from
'
@/utils/device
'
import
{
isMobileDevice
}
from
'
@/utils/device
'
import
type
{
SubscriptionPlan
,
CheckoutInfoResponse
,
OrderType
}
from
'
@/types/payment
'
import
type
{
SubscriptionPlan
,
CheckoutInfoResponse
,
CreateOrderResult
,
OrderType
}
from
'
@/types/payment
'
import
AppLayout
from
'
@/components/layout/AppLayout.vue
'
import
AppLayout
from
'
@/components/layout/AppLayout.vue
'
import
AmountInput
from
'
@/components/payment/AmountInput.vue
'
import
AmountInput
from
'
@/components/payment/AmountInput.vue
'
import
PaymentMethodSelector
from
'
@/components/payment/PaymentMethodSelector.vue
'
import
PaymentMethodSelector
from
'
@/components/payment/PaymentMethodSelector.vue
'
import
{
METHOD_ORDER
,
POPUP_WINDOW_FEATURES
}
from
'
@/components/payment/providerConfig
'
import
{
METHOD_ORDER
,
POPUP_WINDOW_FEATURES
,
STRIPE_POPUP_WINDOW_FEATURES
}
from
'
@/components/payment/providerConfig
'
import
{
PAYMENT_RECOVERY_STORAGE_KEY
,
clearPaymentRecoverySnapshot
,
decidePaymentLaunch
,
getVisibleMethods
,
normalizeVisibleMethod
,
readPaymentRecoverySnapshot
,
type
PaymentRecoverySnapshot
,
writePaymentRecoverySnapshot
,
}
from
'
@/components/payment/paymentFlow
'
import
{
platformAccentBarClass
,
platformBadgeLightClass
,
platformBadgeClass
,
platformTextClass
,
platformLabel
}
from
'
@/utils/platformColors
'
import
{
platformAccentBarClass
,
platformBadgeLightClass
,
platformBadgeClass
,
platformTextClass
,
platformLabel
}
from
'
@/utils/platformColors
'
import
SubscriptionPlanCard
from
'
@/components/payment/SubscriptionPlanCard.vue
'
import
SubscriptionPlanCard
from
'
@/components/payment/SubscriptionPlanCard.vue
'
import
PaymentStatusPanel
from
'
@/components/payment/PaymentStatusPanel.vue
'
import
PaymentStatusPanel
from
'
@/components/payment/PaymentStatusPanel.vue
'
import
StripePaymentInline
from
'
@/components/payment/StripePaymentInline.vue
'
import
Icon
from
'
@/components/icons/Icon.vue
'
import
Icon
from
'
@/components/icons/Icon.vue
'
import
type
{
PaymentMethodOption
}
from
'
@/components/payment/PaymentMethodSelector.vue
'
import
type
{
PaymentMethodOption
}
from
'
@/components/payment/PaymentMethodSelector.vue
'
const
{
t
}
=
useI18n
()
const
{
t
}
=
useI18n
()
const
route
=
useRoute
()
const
route
=
useRoute
()
const
router
=
useRouter
()
const
authStore
=
useAuthStore
()
const
authStore
=
useAuthStore
()
const
paymentStore
=
usePaymentStore
()
const
paymentStore
=
usePaymentStore
()
const
subscriptionStore
=
useSubscriptionStore
()
const
subscriptionStore
=
useSubscriptionStore
()
...
@@ -309,23 +306,41 @@ const selectedMethod = ref('')
...
@@ -309,23 +306,41 @@ const selectedMethod = ref('')
const
selectedPlan
=
ref
<
SubscriptionPlan
|
null
>
(
null
)
const
selectedPlan
=
ref
<
SubscriptionPlan
|
null
>
(
null
)
const
previewImage
=
ref
(
''
)
const
previewImage
=
ref
(
''
)
// Payment phase: 'select' → 'paying' (QR/redirect) or 'stripe' (inline Stripe)
const
paymentPhase
=
ref
<
'
select
'
|
'
paying
'
>
(
'
select
'
)
const
paymentPhase
=
ref
<
'
select
'
|
'
paying
'
|
'
stripe
'
>
(
'
select
'
)
const
paymentState
=
ref
<
{
function
emptyPaymentState
():
PaymentRecoverySnapshot
{
orderId
:
number
return
{
amount
:
number
orderId
:
0
,
qrCode
:
string
amount
:
0
,
expiresAt
:
string
qrCode
:
''
,
paymentType
:
string
expiresAt
:
''
,
payUrl
:
string
paymentType
:
''
,
clientSecret
:
string
payUrl
:
''
,
payAmount
:
number
clientSecret
:
''
,
orderType
:
OrderType
|
''
payAmount
:
0
,
}
>
({
orderId
:
0
,
amount
:
0
,
qrCode
:
''
,
expiresAt
:
''
,
paymentType
:
''
,
payUrl
:
''
,
clientSecret
:
''
,
payAmount
:
0
,
orderType
:
''
}
)
orderType
:
''
,
paymentMode
:
''
,
resumeToken
:
''
,
createdAt
:
0
,
}
}
const
paymentState
=
ref
<
PaymentRecoverySnapshot
>
(
emptyPaymentState
())
function
persistRecoverySnapshot
(
snapshot
:
PaymentRecoverySnapshot
)
{
if
(
typeof
window
===
'
undefined
'
||
!
snapshot
.
orderId
)
return
writePaymentRecoverySnapshot
(
window
.
localStorage
,
snapshot
,
PAYMENT_RECOVERY_STORAGE_KEY
)
}
function
removeRecoverySnapshot
()
{
if
(
typeof
window
===
'
undefined
'
)
return
clearPaymentRecoverySnapshot
(
window
.
localStorage
,
PAYMENT_RECOVERY_STORAGE_KEY
)
}
function
resetPayment
()
{
function
resetPayment
()
{
paymentPhase
.
value
=
'
select
'
paymentPhase
.
value
=
'
select
'
paymentState
.
value
=
{
orderId
:
0
,
amount
:
0
,
qrCode
:
''
,
expiresAt
:
''
,
paymentType
:
''
,
payUrl
:
''
,
clientSecret
:
''
,
payAmount
:
0
,
orderType
:
''
}
paymentState
.
value
=
emptyPaymentState
()
removeRecoverySnapshot
()
}
}
function
onPaymentDone
()
{
function
onPaymentDone
()
{
...
@@ -338,24 +353,15 @@ function onPaymentDone() {
...
@@ -338,24 +353,15 @@ function onPaymentDone() {
}
}
function
onPaymentSuccess
()
{
function
onPaymentSuccess
()
{
removeRecoverySnapshot
()
authStore
.
refreshUser
()
authStore
.
refreshUser
()
if
(
paymentState
.
value
.
orderType
===
'
subscription
'
)
{
if
(
paymentState
.
value
.
orderType
===
'
subscription
'
)
{
subscriptionStore
.
fetchActiveSubscriptions
(
true
).
catch
(()
=>
{
}
)
subscriptionStore
.
fetchActiveSubscriptions
(
true
).
catch
(()
=>
{
}
)
}
}
}
}
function
onStripeDone
()
{
function
onPaymentSettled
()
{
const
wasSubscription
=
paymentState
.
value
.
orderType
===
'
subscription
'
removeRecoverySnapshot
()
resetPayment
()
selectedPlan
.
value
=
null
if
(
wasSubscription
)
{
subscriptionStore
.
fetchActiveSubscriptions
(
true
).
catch
(()
=>
{
}
)
}
}
function
onStripeRedirect
(
orderId
:
number
,
payUrl
:
string
)
{
paymentState
.
value
=
{
...
paymentState
.
value
,
orderId
,
payUrl
,
qrCode
:
''
}
paymentPhase
.
value
=
'
paying
'
}
}
// All checkout data from single API call
// All checkout data from single API call
...
@@ -371,7 +377,8 @@ const tabs = computed(() => {
...
@@ -371,7 +377,8 @@ const tabs = computed(() => {
return
result
return
result
}
)
}
)
const
enabledMethods
=
computed
(()
=>
Object
.
keys
(
checkout
.
value
.
methods
))
const
visibleMethods
=
computed
(()
=>
getVisibleMethods
(
checkout
.
value
.
methods
))
const
enabledMethods
=
computed
(()
=>
Object
.
keys
(
visibleMethods
.
value
))
const
validAmount
=
computed
(()
=>
amount
.
value
??
0
)
const
validAmount
=
computed
(()
=>
amount
.
value
??
0
)
const
balanceRechargeMultiplier
=
computed
(()
=>
{
const
balanceRechargeMultiplier
=
computed
(()
=>
{
const
multiplier
=
checkout
.
value
.
balance_recharge_multiplier
const
multiplier
=
checkout
.
value
.
balance_recharge_multiplier
...
@@ -389,23 +396,33 @@ const planGridClass = computed(() => {
...
@@ -389,23 +396,33 @@ const planGridClass = computed(() => {
// Check if an amount fits a method's [min, max]. 0 = no limit.
// Check if an amount fits a method's [min, max]. 0 = no limit.
function
amountFitsMethod
(
amt
:
number
,
methodType
:
string
):
boolean
{
function
amountFitsMethod
(
amt
:
number
,
methodType
:
string
):
boolean
{
if
(
amt
<=
0
)
return
true
if
(
amt
<=
0
)
return
true
const
ml
=
checkout
.
value
.
methods
[
methodType
]
const
ml
=
visibleMethods
.
value
[
methodType
]
if
(
!
ml
)
return
false
if
(
!
ml
)
return
false
if
(
ml
.
single_min
>
0
&&
amt
<
ml
.
single_min
)
return
false
if
(
ml
.
single_min
>
0
&&
amt
<
ml
.
single_min
)
return
false
if
(
ml
.
single_max
>
0
&&
amt
>
ml
.
single_max
)
return
false
if
(
ml
.
single_max
>
0
&&
amt
>
ml
.
single_max
)
return
false
return
true
return
true
}
}
// Global range for AmountInput (union of all methods, precomputed by backend)
// Visible methods decide the amount range shown to users.
const
globalMinAmount
=
computed
(()
=>
checkout
.
value
.
global_min
)
const
globalMinAmount
=
computed
(()
=>
{
const
globalMaxAmount
=
computed
(()
=>
checkout
.
value
.
global_max
)
const
limits
=
Object
.
values
(
visibleMethods
.
value
)
if
(
limits
.
length
===
0
)
return
0
if
(
limits
.
some
(
limit
=>
limit
.
single_min
<=
0
))
return
0
return
Math
.
min
(...
limits
.
map
(
limit
=>
limit
.
single_min
))
}
)
const
globalMaxAmount
=
computed
(()
=>
{
const
limits
=
Object
.
values
(
visibleMethods
.
value
)
if
(
limits
.
length
===
0
)
return
0
if
(
limits
.
some
(
limit
=>
limit
.
single_max
<=
0
))
return
0
return
Math
.
max
(...
limits
.
map
(
limit
=>
limit
.
single_max
))
}
)
// Selected method's limits (for validation and error messages)
// Selected method's limits (for validation and error messages)
const
selectedLimit
=
computed
(()
=>
checkout
.
value
.
methods
[
selectedMethod
.
value
])
const
selectedLimit
=
computed
(()
=>
visibleMethods
.
value
[
selectedMethod
.
value
])
const
methodOptions
=
computed
<
PaymentMethodOption
[]
>
(()
=>
const
methodOptions
=
computed
<
PaymentMethodOption
[]
>
(()
=>
enabledMethods
.
value
.
map
((
type
)
=>
{
enabledMethods
.
value
.
map
((
type
)
=>
{
const
ml
=
checkout
.
value
.
methods
[
type
]
const
ml
=
visibleMethods
.
value
[
type
]
return
{
return
{
type
,
type
,
fee_rate
:
ml
?.
fee_rate
??
0
,
fee_rate
:
ml
?.
fee_rate
??
0
,
...
@@ -451,7 +468,7 @@ const canSubmit = computed(() =>
...
@@ -451,7 +468,7 @@ const canSubmit = computed(() =>
const
subMethodOptions
=
computed
<
PaymentMethodOption
[]
>
(()
=>
{
const
subMethodOptions
=
computed
<
PaymentMethodOption
[]
>
(()
=>
{
const
planPrice
=
selectedPlan
.
value
?.
price
??
0
const
planPrice
=
selectedPlan
.
value
?.
price
??
0
return
enabledMethods
.
value
.
map
((
type
)
=>
{
return
enabledMethods
.
value
.
map
((
type
)
=>
{
const
ml
=
checkout
.
value
.
methods
[
type
]
const
ml
=
visibleMethods
.
value
[
type
]
return
{
return
{
type
,
type
,
fee_rate
:
ml
?.
fee_rate
??
0
,
fee_rate
:
ml
?.
fee_rate
??
0
,
...
@@ -551,55 +568,58 @@ async function createOrder(orderAmount: number, orderType: OrderType, planId?: n
...
@@ -551,55 +568,58 @@ async function createOrder(orderAmount: number, orderType: OrderType, planId?: n
payment_type
:
selectedMethod
.
value
,
payment_type
:
selectedMethod
.
value
,
order_type
:
orderType
,
order_type
:
orderType
,
plan_id
:
planId
,
plan_id
:
planId
,
}
)
}
)
as
CreateOrderResult
&
{
resume_token
?:
string
}
const
openWindow
=
(
url
:
string
)
=>
{
const
openWindow
=
(
url
:
string
,
features
=
POPUP_WINDOW_FEATURES
)
=>
{
const
win
=
window
.
open
(
url
,
'
paymentPopup
'
,
POPUP_WINDOW_FEATURES
)
const
win
=
window
.
open
(
url
,
'
paymentPopup
'
,
features
)
if
(
!
win
||
win
.
closed
)
{
if
(
!
win
||
win
.
closed
)
{
window
.
location
.
href
=
url
window
.
location
.
href
=
url
}
}
}
}
if
(
result
.
client_secret
)
{
const
visibleMethod
=
normalizeVisibleMethod
(
selectedMethod
.
value
)
||
selectedMethod
.
value
// Stripe: show Payment Element inline (user picks method → confirms → redirect if needed)
const
stripeMethod
=
visibleMethod
===
'
wxpay
'
?
'
wechat_pay
'
:
'
alipay
'
paymentState
.
value
=
{
const
stripeRouteUrl
=
result
.
client_secret
orderId
:
result
.
order_id
,
amount
:
result
.
amount
,
qrCode
:
''
,
expiresAt
:
result
.
expires_at
||
''
,
?
router
.
resolve
({
paymentType
:
selectedMethod
.
value
,
payUrl
:
''
,
path
:
'
/payment/stripe
'
,
clientSecret
:
result
.
client_secret
,
payAmount
:
result
.
pay_amount
,
query
:
{
orderType
,
order_id
:
String
(
result
.
order_id
),
}
client_secret
:
result
.
client_secret
,
paymentPhase
.
value
=
'
stripe
'
method
:
stripeMethod
,
}
else
if
(
isMobileDevice
()
&&
result
.
pay_url
)
{
resume_token
:
result
.
resume_token
||
undefined
,
// Mobile + pay_url: redirect directly instead of QR/popup (mobile browsers block popups)
}
,
paymentState
.
value
=
{
}
).
href
orderId
:
result
.
order_id
,
amount
:
result
.
amount
,
qrCode
:
''
,
expiresAt
:
result
.
expires_at
||
''
,
:
''
paymentType
:
selectedMethod
.
value
,
payUrl
:
result
.
pay_url
,
const
decision
=
decidePaymentLaunch
(
result
,
{
clientSecret
:
''
,
payAmount
:
0
,
visibleMethod
,
orderType
,
orderType
,
}
isMobile
:
isMobileDevice
(),
paymentPhase
.
value
=
'
paying
'
stripePopupUrl
:
stripeRouteUrl
,
window
.
location
.
href
=
result
.
pay_url
stripeRouteUrl
,
return
}
)
}
else
if
(
result
.
qr_code
)
{
// QR mode: show QR code inline
if
(
decision
.
kind
===
'
unhandled
'
)
{
paymentState
.
value
=
{
orderId
:
result
.
order_id
,
amount
:
result
.
amount
,
qrCode
:
result
.
qr_code
,
expiresAt
:
result
.
expires_at
||
''
,
paymentType
:
selectedMethod
.
value
,
payUrl
:
''
,
clientSecret
:
''
,
payAmount
:
0
,
orderType
,
}
paymentPhase
.
value
=
'
paying
'
}
else
if
(
result
.
pay_url
)
{
// Redirect/popup mode: open payment URL, show waiting state inline
openWindow
(
result
.
pay_url
)
paymentState
.
value
=
{
orderId
:
result
.
order_id
,
amount
:
result
.
amount
,
qrCode
:
''
,
expiresAt
:
result
.
expires_at
||
''
,
paymentType
:
selectedMethod
.
value
,
payUrl
:
result
.
pay_url
,
clientSecret
:
''
,
payAmount
:
0
,
orderType
,
}
paymentPhase
.
value
=
'
paying
'
}
else
{
errorMessage
.
value
=
t
(
'
payment.result.failed
'
)
errorMessage
.
value
=
t
(
'
payment.result.failed
'
)
appStore
.
showError
(
errorMessage
.
value
)
appStore
.
showError
(
errorMessage
.
value
)
return
}
paymentState
.
value
=
decision
.
paymentState
paymentPhase
.
value
=
'
paying
'
persistRecoverySnapshot
(
decision
.
recovery
)
if
(
decision
.
kind
===
'
stripe_popup
'
)
{
openWindow
(
decision
.
paymentState
.
payUrl
,
STRIPE_POPUP_WINDOW_FEATURES
)
return
}
if
(
decision
.
kind
===
'
stripe_route
'
)
{
window
.
location
.
href
=
decision
.
paymentState
.
payUrl
return
}
if
(
decision
.
kind
===
'
redirect_waiting
'
&&
decision
.
paymentState
.
payUrl
)
{
if
(
isMobileDevice
())
{
window
.
location
.
href
=
decision
.
paymentState
.
payUrl
return
}
openWindow
(
decision
.
paymentState
.
payUrl
)
}
}
}
catch
(
err
:
unknown
)
{
}
catch
(
err
:
unknown
)
{
const
apiErr
=
err
as
Record
<
string
,
unknown
>
const
apiErr
=
err
as
Record
<
string
,
unknown
>
...
@@ -630,6 +650,25 @@ onMounted(async () => {
...
@@ -630,6 +650,25 @@ onMounted(async () => {
}
)
}
)
selectedMethod
.
value
=
sorted
[
0
]
selectedMethod
.
value
=
sorted
[
0
]
}
}
if
(
typeof
window
!==
'
undefined
'
)
{
const
routeResumeToken
=
typeof
route
.
query
.
resume_token
===
'
string
'
?
route
.
query
.
resume_token
:
undefined
const
restored
=
readPaymentRecoverySnapshot
(
window
.
localStorage
.
getItem
(
PAYMENT_RECOVERY_STORAGE_KEY
),
{
resumeToken
:
routeResumeToken
}
,
)
if
(
restored
)
{
paymentState
.
value
=
restored
paymentPhase
.
value
=
'
paying
'
const
restoredMethod
=
normalizeVisibleMethod
(
restored
.
paymentType
)
if
(
restoredMethod
)
{
selectedMethod
.
value
=
restoredMethod
}
}
else
{
removeRecoverySnapshot
()
}
}
if
(
checkout
.
value
.
balance_disabled
)
{
if
(
checkout
.
value
.
balance_disabled
)
{
activeTab
.
value
=
'
subscription
'
activeTab
.
value
=
'
subscription
'
}
}
...
...
Prev
1
…
3
4
5
6
7
Next
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
.
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment