refactor(web): simplify billing portal link (#40131)

This commit is contained in:
yyh authored and GitHub committed 2026-08-07 04:26:18 +00:00
1 parent 7422052759
commit b8330df96e
7 files changed
+130 -248

No files matched your search

@@ -34,6 +34,9 @@ const render = (ui: ReactElement, options: RenderOptions = {}, vectorSpaceUsageU
limit: plan.total.vectorSpace,
usage_unknown: vectorSpaceUsageUnknown,
})
queryClient.setQueryData(consoleQuery.billing.invoices.get.queryOptions().queryKey, {
url: 'https://billing.example.com',
})
const { wrapper } = createConsoleQueryWrapper({
systemFeatures: { deployment_edition: 'CLOUD' },
queryClient,
@@ -71,17 +74,6 @@ vi.mock('@/context/i18n', () => ({
useGetPricingPageLanguage: () => 'en',
}))
// ─── Service mocks ──────────────────────────────────────────────────────────
const mockRefetch = vi.fn().mockResolvedValue({ data: 'https://billing.example.com' })
vi.mock('@/service/use-billing', () => ({
useBillingUrl: () => ({
data: 'https://billing.example.com',
isFetching: false,
refetch: mockRefetch,
}),
useBindPartnerStackInfo: () => ({ mutateAsync: vi.fn() }),
}))
vi.mock('@/service/use-education', () => ({
useEducationVerify: () => ({
mutateAsync: vi.fn().mockResolvedValue({ token: 'test-token' }),
@@ -97,10 +89,6 @@ vi.mock('@/next/navigation', () => ({
useSearchParams: () => new URLSearchParams(),
}))
vi.mock('@/hooks/use-async-window-open', () => ({
useAsyncWindowOpen: () => vi.fn(),
}))
// ─── External component mocks ───────────────────────────────────────────────
vi.mock('@/app/education-apply/verify-state-modal', () => ({
default: ({ isShow }: { isShow: boolean }) =>
@@ -78,14 +78,6 @@ vi.mock('@/service/use-education', () => ({
}),
}))
vi.mock('@/service/use-billing', () => ({
useBillingUrl: () => ({
data: 'https://billing.example.com',
isFetching: false,
refetch: vi.fn(),
}),
}))
// ─── Navigation mocks ───────────────────────────────────────────────────────
vi.mock('@/next/navigation', () => ({
useRouter: () => ({ push: mockRouterPush }),
@@ -93,10 +85,6 @@ vi.mock('@/next/navigation', () => ({
useSearchParams: () => new URLSearchParams(),
}))
vi.mock('@/hooks/use-async-window-open', () => ({
useAsyncWindowOpen: () => vi.fn(),
}))
// ─── External component mocks ───────────────────────────────────────────────
vi.mock('@/app/education-apply/verify-state-modal', () => ({
default: ({
@@ -7,19 +7,26 @@
* Covers URL param reading, cookie persistence, API bind on mount,
* cookie cleanup after successful bind, and error handling for 400 status.
*/
import { act, cleanup, renderHook, waitFor } from '@testing-library/react'
import { act, cleanup, waitFor } from '@testing-library/react'
import Cookies from 'js-cookie'
import * as React from 'react'
import usePSInfo from '@/app/components/billing/partner-stack/use-ps-info'
import { PARTNER_STACK_CONFIG } from '@/config'
import { renderWithConsoleQuery } from '@/test/console/query-data'
import { renderHookWithConsoleQuery, renderWithConsoleQuery } from '@/test/console/query-data'
const render = (ui: React.ReactElement) =>
renderWithConsoleQuery(ui, { systemFeatures: { deployment_edition: 'CLOUD' } })
const renderPSInfo = () =>
renderHookWithConsoleQuery(() => usePSInfo(), {
systemFeatures: { deployment_edition: 'CLOUD' },
})
// ─── Mock state ──────────────────────────────────────────────────────────────
let mockSearchParams = new URLSearchParams()
const mockMutateAsync = vi.fn()
const mocks = vi.hoisted(() => ({
request: vi.fn(),
}))
// ─── Module mocks ────────────────────────────────────────────────────────────
vi.mock('@/next/navigation', () => ({
@@ -28,15 +35,9 @@ vi.mock('@/next/navigation', () => ({
usePathname: () => '/',
}))
vi.mock('@/service/use-billing', () => ({
useBindPartnerStackInfo: () => ({
mutateAsync: mockMutateAsync,
}),
useBillingUrl: () => ({
data: '',
isFetching: false,
refetch: vi.fn(),
}),
vi.mock('@/service/base', () => ({
request: (...args: unknown[]) => mocks.request(...args),
sseGeneratorPost: vi.fn(),
}))
vi.mock('@/config', async (importOriginal) => {
@@ -69,6 +70,8 @@ const clearCookie = () => {
Cookies.remove(PARTNER_STACK_CONFIG.cookieName)
}
const getBindRequest = () => mocks.request.mock.calls[0]?.[2]?.request as Request | undefined
// ═══════════════════════════════════════════════════════════════════════════════
describe('Partner Stack Flow', () => {
beforeEach(() => {
@@ -76,7 +79,7 @@ describe('Partner Stack Flow', () => {
cleanup()
clearCookie()
mockSearchParams = new URLSearchParams()
mockMutateAsync.mockResolvedValue({})
mocks.request.mockResolvedValue(Response.json({}))
})
// ─── 1. URL Param Reading ───────────────────────────────────────────────
@@ -87,7 +90,7 @@ describe('Partner Stack Flow', () => {
ps_xid: 'click-456',
})
const { result } = renderHook(() => usePSInfo())
const { result } = renderPSInfo()
expect(result.current.psPartnerKey).toBe('partner-123')
expect(result.current.psClickId).toBe('click-456')
@@ -96,7 +99,7 @@ describe('Partner Stack Flow', () => {
it('should fall back to cookie when URL params are not present', () => {
setCookieData({ partnerKey: 'cookie-partner', clickId: 'cookie-click' })
const { result } = renderHook(() => usePSInfo())
const { result } = renderPSInfo()
expect(result.current.psPartnerKey).toBe('cookie-partner')
expect(result.current.psClickId).toBe('cookie-click')
@@ -109,14 +112,14 @@ describe('Partner Stack Flow', () => {
ps_xid: 'url-click',
})
const { result } = renderHook(() => usePSInfo())
const { result } = renderPSInfo()
expect(result.current.psPartnerKey).toBe('url-partner')
expect(result.current.psClickId).toBe('url-click')
})
it('should return null for both values when no params and no cookie', () => {
const { result } = renderHook(() => usePSInfo())
const { result } = renderPSInfo()
expect(result.current.psPartnerKey).toBeUndefined()
expect(result.current.psClickId).toBeUndefined()
@@ -131,7 +134,7 @@ describe('Partner Stack Flow', () => {
ps_xid: 'new-click',
})
const { result } = renderHook(() => usePSInfo())
const { result } = renderPSInfo()
act(() => result.current.saveOrUpdate())
const cookieData = getCookieData()
@@ -149,7 +152,7 @@ describe('Partner Stack Flow', () => {
})
const cookieSetSpy = vi.spyOn(Cookies, 'set')
const { result } = renderHook(() => usePSInfo())
const { result } = renderPSInfo()
act(() => result.current.saveOrUpdate())
// Should not call set because values haven't changed
@@ -163,7 +166,7 @@ describe('Partner Stack Flow', () => {
})
const cookieSetSpy = vi.spyOn(Cookies, 'set')
const { result } = renderHook(() => usePSInfo())
const { result } = renderPSInfo()
act(() => result.current.saveOrUpdate())
expect(cookieSetSpy).not.toHaveBeenCalled()
@@ -176,7 +179,7 @@ describe('Partner Stack Flow', () => {
})
const cookieSetSpy = vi.spyOn(Cookies, 'set')
const { result } = renderHook(() => usePSInfo())
const { result } = renderPSInfo()
act(() => result.current.saveOrUpdate())
expect(cookieSetSpy).not.toHaveBeenCalled()
@@ -186,21 +189,22 @@ describe('Partner Stack Flow', () => {
// ─── 3. Bind API Flow ──────────────────────────────────────────────────
describe('Bind API flow', () => {
it('should call mutateAsync with partnerKey and clickId on bind', async () => {
it('should bind the partner key and click ID through the billing API', async () => {
mockSearchParams = new URLSearchParams({
ps_partner_key: 'bind-partner',
ps_xid: 'bind-click',
})
const { result } = renderHook(() => usePSInfo())
const { result } = renderPSInfo()
await act(async () => {
await result.current.bind()
})
expect(mockMutateAsync).toHaveBeenCalledWith({
partnerKey: 'bind-partner',
clickId: 'bind-click',
})
expect(mocks.request).toHaveBeenCalledTimes(1)
const request = getBindRequest()
expect(request?.method).toBe('PUT')
expect(request?.url).toContain('/billing/partners/bind-partner/tenants')
await expect(request?.clone().json()).resolves.toEqual({ click_id: 'bind-click' })
})
it('should remove cookie after successful bind', async () => {
@@ -210,7 +214,7 @@ describe('Partner Stack Flow', () => {
ps_xid: 'rm-click',
})
const { result } = renderHook(() => usePSInfo())
const { result } = renderPSInfo()
await act(async () => {
await result.current.bind()
})
@@ -220,14 +224,14 @@ describe('Partner Stack Flow', () => {
})
it('should remove cookie on 400 error (already bound)', async () => {
mockMutateAsync.mockRejectedValue({ status: 400 })
mocks.request.mockRejectedValue({ status: 400 })
setCookieData({ partnerKey: 'err-partner', clickId: 'err-click' })
mockSearchParams = new URLSearchParams({
ps_partner_key: 'err-partner',
ps_xid: 'err-click',
})
const { result } = renderHook(() => usePSInfo())
const { result } = renderPSInfo()
await act(async () => {
await result.current.bind()
})
@@ -237,14 +241,14 @@ describe('Partner Stack Flow', () => {
})
it('should not remove cookie on non-400 errors', async () => {
mockMutateAsync.mockRejectedValue({ status: 500 })
mocks.request.mockRejectedValue({ status: 500 })
setCookieData({ partnerKey: 'keep-partner', clickId: 'keep-click' })
mockSearchParams = new URLSearchParams({
ps_partner_key: 'keep-partner',
ps_xid: 'keep-click',
})
const { result } = renderHook(() => usePSInfo())
const { result } = renderPSInfo()
await act(async () => {
await result.current.bind()
})
@@ -259,12 +263,12 @@ describe('Partner Stack Flow', () => {
ps_xid: 'click-only',
})
const { result } = renderHook(() => usePSInfo())
const { result } = renderPSInfo()
await act(async () => {
await result.current.bind()
})
expect(mockMutateAsync).not.toHaveBeenCalled()
expect(mocks.request).not.toHaveBeenCalled()
})
it('should not call bind a second time (idempotency)', async () => {
@@ -273,19 +277,19 @@ describe('Partner Stack Flow', () => {
ps_xid: 'click-once',
})
const { result } = renderHook(() => usePSInfo())
const { result } = renderPSInfo()
// First bind
await act(async () => {
await result.current.bind()
})
expect(mockMutateAsync).toHaveBeenCalledTimes(1)
expect(mocks.request).toHaveBeenCalledTimes(1)
// Second bind should be skipped (hasBind = true)
await act(async () => {
await result.current.bind()
})
expect(mockMutateAsync).toHaveBeenCalledTimes(1)
expect(mocks.request).toHaveBeenCalledTimes(1)
})
})
@@ -303,10 +307,8 @@ describe('Partner Stack Flow', () => {
render(<PartnerStack />)
await waitFor(() => {
expect(mockMutateAsync).toHaveBeenCalledWith({
partnerKey: 'mount-partner',
clickId: 'mount-click',
})
expect(mocks.request).toHaveBeenCalledTimes(1)
expect(getBindRequest()?.url).toContain('/billing/partners/mount-partner/tenants')
expect(Cookies.get(PARTNER_STACK_CONFIG.cookieName)).toBeUndefined()
})
})
@@ -1,37 +1,21 @@
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { consoleQuery } from '@/service/client'
import { createConsoleQueryClient, createConsoleQueryWrapper } from '@/test/console/query-data'
import { render } from '@/test/console/render'
import Billing from '../index'
let currentBillingUrl: string | null = 'https://billing'
let fetching = false
let currentBillingUrl: string | undefined = 'https://billing.example.com'
let isManager = true
let enableBilling = true
let billingUrlEnabled = false
const refetchMock = vi.fn()
const openAsyncWindowMock = vi.fn()
type BillingUrlCallback = () => Promise<string | null>
type BillingWindowOptions = {
immediateUrl: string | null
features: string
onError: (err: Error) => void
}
type OpenAsyncWindowCall = [BillingUrlCallback, BillingWindowOptions]
vi.mock('@/service/use-billing', () => ({
useBillingUrl: (enabled: boolean) => {
billingUrlEnabled = enabled
return {
data: currentBillingUrl,
isFetching: fetching,
refetch: refetchMock,
}
},
const mocks = vi.hoisted(() => ({
request: vi.fn(() => new Promise(() => {})),
}))
vi.mock('@/hooks/use-async-window-open', () => ({
useAsyncWindowOpen: () => openAsyncWindowMock,
vi.mock('@/service/base', () => ({
request: mocks.request,
sseGeneratorPost: vi.fn(),
}))
vi.mock('@/context/workspace-state', async () => {
@@ -51,113 +35,67 @@ vi.mock('../../plan', () => ({
default: ({ loc }: { loc: string }) => <div data-testid="plan-component" data-loc={loc} />,
}))
const renderBilling = () => {
const queryClient = createConsoleQueryClient()
if (currentBillingUrl) {
queryClient.setQueryData(consoleQuery.billing.invoices.get.queryOptions().queryKey, {
url: currentBillingUrl,
})
}
const { wrapper } = createConsoleQueryWrapper({ queryClient })
return render(<Billing />, { wrapper })
}
describe('Billing', () => {
beforeEach(() => {
vi.clearAllMocks()
currentBillingUrl = 'https://billing'
fetching = false
currentBillingUrl = 'https://billing.example.com'
isManager = true
enableBilling = true
billingUrlEnabled = false
refetchMock.mockResolvedValue({ data: 'https://billing' })
})
it('renders the billing portal as a keyboard-accessible external link for workspace managers', async () => {
const user = userEvent.setup()
renderBilling()
const billingLink = screen.getByRole('link', { name: /billing\.viewBillingTitle/ })
expect(billingLink).toHaveAttribute('href', currentBillingUrl)
expect(billingLink).toHaveAttribute('target', '_blank')
expect(billingLink).toHaveAttribute('rel', 'noopener noreferrer')
await user.tab()
expect(billingLink).toHaveFocus()
})
it('hides the billing action from non-manager members', () => {
isManager = false
render(<Billing />)
renderBilling()
expect(
screen.queryByRole('button', { name: /billing\.viewBillingTitle/ }),
).not.toBeInTheDocument()
expect(billingUrlEnabled).toBe(false)
})
it('shows the billing action to managers without billing permission keys', () => {
render(<Billing />)
expect(screen.getByRole('button', { name: /billing\.viewBillingTitle/ })).toBeInTheDocument()
expect(billingUrlEnabled).toBe(true)
expect(screen.queryByText('billing.viewBillingTitle')).not.toBeInTheDocument()
})
it('hides the billing action when billing is disabled', () => {
enableBilling = false
render(<Billing />)
renderBilling()
expect(screen.queryByText('billing.viewBillingTitle')).not.toBeInTheDocument()
})
it('renders the billing action outside the tab order before the URL is available', async () => {
const user = userEvent.setup()
currentBillingUrl = undefined
renderBilling()
expect(screen.getByText('billing.viewBillingTitle')).toBeInTheDocument()
expect(
screen.queryByRole('button', { name: /billing\.viewBillingTitle/ }),
screen.queryByRole('link', { name: /billing\.viewBillingTitle/ }),
).not.toBeInTheDocument()
expect(billingUrlEnabled).toBe(false)
})
it('opens the billing window with the immediate url when the button is clicked', async () => {
render(<Billing />)
const actionButton = screen.getByRole('button', { name: /billing\.viewBillingTitle/ })
fireEvent.click(actionButton)
await waitFor(() => expect(openAsyncWindowMock).toHaveBeenCalled())
const [, options] = openAsyncWindowMock.mock.calls[0] as OpenAsyncWindowCall
expect(options).toMatchObject({
immediateUrl: currentBillingUrl,
features: 'noopener,noreferrer',
})
})
it('returns the refetched url from the async callback', async () => {
const newUrl = 'https://new-billing-url'
refetchMock.mockResolvedValue({ data: newUrl })
render(<Billing />)
const actionButton = screen.getByRole('button', { name: /billing\.viewBillingTitle/ })
fireEvent.click(actionButton)
await waitFor(() => expect(openAsyncWindowMock).toHaveBeenCalled())
const [asyncCallback] = openAsyncWindowMock.mock.calls[0] as OpenAsyncWindowCall
// Execute the async callback passed to openAsyncWindow
const result = await asyncCallback()
expect(result).toBe(newUrl)
expect(refetchMock).toHaveBeenCalled()
})
it('returns null when refetch returns no url', async () => {
refetchMock.mockResolvedValue({ data: null })
render(<Billing />)
const actionButton = screen.getByRole('button', { name: /billing\.viewBillingTitle/ })
fireEvent.click(actionButton)
await waitFor(() => expect(openAsyncWindowMock).toHaveBeenCalled())
const [asyncCallback] = openAsyncWindowMock.mock.calls[0] as OpenAsyncWindowCall
// Execute the async callback when url is null
const result = await asyncCallback()
expect(result).toBeNull()
})
it('handles errors in onError callback', async () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
render(<Billing />)
const actionButton = screen.getByRole('button', { name: /billing\.viewBillingTitle/ })
fireEvent.click(actionButton)
await waitFor(() => expect(openAsyncWindowMock).toHaveBeenCalled())
const [, options] = openAsyncWindowMock.mock.calls[0] as OpenAsyncWindowCall
// Execute the onError callback
const testError = new Error('Test error')
options.onError(testError)
expect(consoleError).toHaveBeenCalledWith('Failed to fetch billing url', testError)
consoleError.mockRestore()
})
it('disables the button while billing url is fetching', () => {
fetching = true
render(<Billing />)
const actionButton = screen.getByRole('button', { name: /billing\.viewBillingTitle/ })
expect(actionButton)!.toBeDisabled()
await user.tab()
expect(document.body).toHaveFocus()
})
})
@@ -1,51 +1,38 @@
'use client'
import type { FC } from 'react'
import { cn } from '@langgenius/dify-ui/cn'
import { useQuery } from '@tanstack/react-query'
import { useAtomValue } from 'jotai'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import { useProviderContext } from '@/context/provider-context'
import { isCurrentWorkspaceManagerAtom } from '@/context/workspace-state'
import { useAsyncWindowOpen } from '@/hooks/use-async-window-open'
import { useBillingUrl } from '@/service/use-billing'
import { consoleQuery } from '@/service/client'
import PlanComp from '../plan'
const Billing: FC = () => {
const { t } = useTranslation()
const isCurrentWorkspaceManager = useAtomValue(isCurrentWorkspaceManagerAtom)
const { enableBilling } = useProviderContext()
const {
data: billingUrl,
isFetching,
refetch,
} = useBillingUrl(enableBilling && isCurrentWorkspaceManager)
const openAsyncWindow = useAsyncWindowOpen()
const handleOpenBilling = async () => {
await openAsyncWindow(
async () => {
const url = (await refetch()).data
if (url) return url
return null
},
{
immediateUrl: billingUrl,
features: 'noopener,noreferrer',
onError: (err) => {
console.error('Failed to fetch billing url', err)
},
},
)
}
const canManageBilling = enableBilling && isCurrentWorkspaceManager
const { data: billing } = useQuery(
consoleQuery.billing.invoices.get.queryOptions({ enabled: canManageBilling }),
)
const billingUrl = billing?.url
return (
<div>
<PlanComp loc="billing-page" />
{enableBilling && isCurrentWorkspaceManager && (
<button
type="button"
className="mt-3 flex w-full items-center justify-between rounded-xl bg-background-section-burn px-4 py-3"
onClick={handleOpenBilling}
disabled={isFetching}
{canManageBilling && (
<a
className={cn(
'mt-3 flex w-full items-center justify-between rounded-xl bg-background-section-burn px-4 py-3 outline-hidden',
billingUrl &&
'hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid',
)}
href={billingUrl}
target={billingUrl ? '_blank' : undefined}
rel={billingUrl ? 'noopener noreferrer' : undefined}
>
<div className="flex flex-col gap-0.5 text-left">
<div className="system-md-semibold text-text-primary">
@@ -55,13 +42,13 @@ const Billing: FC = () => {
{t(($) => $.viewBillingDescription, { ns: 'billing' })}
</div>
</div>
<span className="inline-flex h-8 w-24 items-center justify-center gap-0.5 rounded-lg border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg px-3 py-2 text-saas-dify-blue-accessible shadow-[0_1px_2px_rgba(9,9,11,0.05)] backdrop-blur-[5px]">
<span className="inline-flex h-8 w-24 items-center justify-center gap-0.5 rounded-lg bg-components-button-secondary-bg px-3 py-2 text-saas-dify-blue-accessible shadow-[0_1px_2px_rgba(9,9,11,0.05)] inset-ring-[0.5px] inset-ring-components-button-secondary-border backdrop-blur-[5px]">
<span className="system-sm-medium leading-none">
{t(($) => $.viewBillingAction, { ns: 'billing' })}
</span>
<span className="i-ri-arrow-right-up-line size-4" />
<span className="i-ri-arrow-right-up-line size-4" aria-hidden="true" />
</span>
</button>
</a>
)}
</div>
)
@@ -1,9 +1,10 @@
import { useMutation } from '@tanstack/react-query'
import { useBoolean } from 'ahooks'
import Cookies from 'js-cookie'
import { useCallback } from 'react'
import { PARTNER_STACK_CONFIG } from '@/config'
import { useSearchParams } from '@/next/navigation'
import { useBindPartnerStackInfo } from '@/service/use-billing'
import { consoleQuery } from '@/service/client'
const usePSInfo = () => {
const searchParams = useSearchParams()
@@ -20,7 +21,9 @@ const usePSInfo = () => {
const isPSChanged =
psInfoInCookie?.partnerKey !== psPartnerKey || psInfoInCookie?.clickId !== psClickId
const [hasBind, { setTrue: setBind }] = useBoolean(false)
const { mutateAsync } = useBindPartnerStackInfo()
const { mutateAsync: bindPartnerStackInfo } = useMutation(
consoleQuery.billing.partners.byPartnerKey.tenants.put.mutationOptions(),
)
// Save to top domain. cloud.dify.ai => .dify.ai
const domain = globalThis.location?.hostname.replace('cloud', '')
@@ -45,9 +48,9 @@ const usePSInfo = () => {
if (psPartnerKey && psClickId && !hasBind) {
let shouldRemoveCookie = false
try {
await mutateAsync({
partnerKey: psPartnerKey,
clickId: psClickId,
await bindPartnerStackInfo({
params: { partner_key: psPartnerKey },
body: { click_id: psClickId },
})
shouldRemoveCookie = true
} catch (error: unknown) {
@@ -56,7 +59,7 @@ const usePSInfo = () => {
if (shouldRemoveCookie) Cookies.remove(PARTNER_STACK_CONFIG.cookieName, { path: '/', domain })
setBind()
}
}, [psPartnerKey, psClickId, hasBind, domain, setBind, mutateAsync])
}, [psPartnerKey, psClickId, hasBind, domain, setBind, bindPartnerStackInfo])
return {
psPartnerKey,
psClickId,
-24
View File
@@ -1,24 +0,0 @@
import { useMutation, useQuery } from '@tanstack/react-query'
import { consoleClient, consoleQuery } from '@/service/client'
export const useBindPartnerStackInfo = () => {
return useMutation({
mutationKey: consoleQuery.billing.partners.byPartnerKey.tenants.put.mutationKey(),
mutationFn: (data: { partnerKey: string; clickId: string }) =>
consoleClient.billing.partners.byPartnerKey.tenants.put({
params: { partner_key: data.partnerKey },
body: { click_id: data.clickId },
}),
})
}
export const useBillingUrl = (enabled: boolean) => {
return useQuery({
queryKey: consoleQuery.billing.invoices.get.queryKey(),
enabled,
queryFn: async () => {
const res = await consoleClient.billing.invoices.get()
return res.url
},
})
}