diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 5975c67e41..e16481dbfd 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -222,8 +222,8 @@ importers: specifier: ^3.10.0 version: 3.10.0(react-hook-form@7.71.2(react@19.2.4)) '@raystack/proton': - specifier: 0.1.0-f0b06e61f1985a9052f32744976a2b73d8c56839 - version: 0.1.0-f0b06e61f1985a9052f32744976a2b73d8c56839(@tanstack/query-core@5.90.20)(@tanstack/react-query@5.90.21(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + specifier: 0.1.0-194685ed0280d282261bc16f708266465dd1deb1 + version: 0.1.0-194685ed0280d282261bc16f708266465dd1deb1(@tanstack/query-core@5.90.20)(@tanstack/react-query@5.90.21(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@tanstack/react-query': specifier: ^5.90.2 version: 5.90.21(react@19.2.4) @@ -1587,16 +1587,16 @@ packages: '@types/react': optional: true - '@raystack/proton@0.1.0-859ba765e6cfd44736ddcf42664b742fe7fd916e': - resolution: {integrity: sha512-82QvGoJjcp5zGt/QIkdSJEn2iX8vn/g2H4jm8O3QH3YIZuZp0bXFXo+ddD5S49tSUtWiegi7qWJargHgBceLPg==} + '@raystack/proton@0.1.0-194685ed0280d282261bc16f708266465dd1deb1': + resolution: {integrity: sha512-e1u+Tky38WpufUe9JO4KzIdJ49BbQDOFMayL5UiS/KPnvWR0xVgFleECbQ/xGqiwisTERYMXqjUbKvfpfyctgw==} peerDependencies: '@tanstack/react-query': ^5.0.0 peerDependenciesMeta: '@tanstack/react-query': optional: true - '@raystack/proton@0.1.0-f0b06e61f1985a9052f32744976a2b73d8c56839': - resolution: {integrity: sha512-YCzbMMyYaf5naOZdHYe01x5Ln0FFmABWwHDD3Oi6ANtQIZn1UmNsl8s3zcuhJ7dgQfGT87rFDeRBLm1ihxL2QQ==} + '@raystack/proton@0.1.0-859ba765e6cfd44736ddcf42664b742fe7fd916e': + resolution: {integrity: sha512-82QvGoJjcp5zGt/QIkdSJEn2iX8vn/g2H4jm8O3QH3YIZuZp0bXFXo+ddD5S49tSUtWiegi7qWJargHgBceLPg==} peerDependencies: '@tanstack/react-query': ^5.0.0 peerDependenciesMeta: @@ -2224,6 +2224,7 @@ packages: '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + deprecated: Potential CWE-502 - Update to 1.3.1 or higher '@unrs/resolver-binding-android-arm-eabi@1.11.1': resolution: {integrity: sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==} @@ -2584,6 +2585,7 @@ packages: basic-ftp@5.2.0: resolution: {integrity: sha512-VoMINM2rqJwJgfdHq6RiUudKt2BV+FY5ZFezP/ypmwayk68+NzzAQy4XXLlqsGD4MCzq3DrmNFD/uUmBJuGoXw==} engines: {node: '>=10.0.0'} + deprecated: Security vulnerability fixed in 5.2.1, please upgrade before-after-hook@2.2.3: resolution: {integrity: sha512-NzUnlZexiaH/46WDhANlyR2bXRopNg4F/zuSA3OpZnllCUgRaOF2znDioDWrmbNVsuZk6l9pMquQB38cfBZwkQ==} @@ -6309,6 +6311,7 @@ packages: tsconfck@3.1.6: resolution: {integrity: sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==} engines: {node: ^18 || >=20} + deprecated: unmaintained hasBin: true peerDependencies: typescript: ^5.0.0 @@ -8073,7 +8076,7 @@ snapshots: - '@date-fns/tz' - date-fns - '@raystack/proton@0.1.0-859ba765e6cfd44736ddcf42664b742fe7fd916e(@tanstack/query-core@5.90.20)(@tanstack/react-query@5.90.21(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@raystack/proton@0.1.0-194685ed0280d282261bc16f708266465dd1deb1(@tanstack/query-core@5.90.20)(@tanstack/react-query@5.90.21(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: '@bufbuild/protobuf': 2.11.0 '@connectrpc/connect': 2.1.1(@bufbuild/protobuf@2.11.0) @@ -8087,7 +8090,7 @@ snapshots: - react - react-dom - '@raystack/proton@0.1.0-f0b06e61f1985a9052f32744976a2b73d8c56839(@tanstack/query-core@5.90.20)(@tanstack/react-query@5.90.21(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@raystack/proton@0.1.0-859ba765e6cfd44736ddcf42664b742fe7fd916e(@tanstack/query-core@5.90.20)(@tanstack/react-query@5.90.21(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: '@bufbuild/protobuf': 2.11.0 '@connectrpc/connect': 2.1.1(@bufbuild/protobuf@2.11.0) diff --git a/web/sdk/client/hooks/useTokens.ts b/web/sdk/client/hooks/useTokens.ts index cfbdf6aebd..2aadf058f8 100644 --- a/web/sdk/client/hooks/useTokens.ts +++ b/web/sdk/client/hooks/useTokens.ts @@ -11,7 +11,13 @@ interface UseTokensReturn { fetchTokenBalance: () => Promise; } -export const useTokens = (): UseTokensReturn => { +export interface UseTokensOptions { + // when false the balance is not fetched; callers that only need the + // balance in a rarely-opened surface (like a dialog) gate it on that + enabled?: boolean; +} + +export const useTokens = (options: UseTokensOptions = {}): UseTokensReturn => { const { billingAccount } = useFrontier(); const { @@ -25,7 +31,7 @@ export const useTokens = (): UseTokensReturn => { id: billingAccount?.id ?? '' }), { - enabled: !!billingAccount?.id, + enabled: !!billingAccount?.id && (options.enabled ?? true), retry: false } ); diff --git a/web/sdk/client/utils/invoice-queries.ts b/web/sdk/client/utils/invoice-queries.ts new file mode 100644 index 0000000000..7692403678 --- /dev/null +++ b/web/sdk/client/utils/invoice-queries.ts @@ -0,0 +1,23 @@ +import { create } from '@bufbuild/protobuf'; +import { RQLFilterSchema } from '@raystack/proton/frontier'; +import { INVOICE_STATES } from './constants'; + +// The one definition of "an invoice the customer still has to pay": open +// state with a non-zero amount. The server refuses an organization delete +// while any exist, and the billing page's payment-issue banner keys off the +// same set — both build their queries from these filters so the two can not +// drift apart. +export function openInvoiceFilters() { + return [ + create(RQLFilterSchema, { + name: 'state', + operator: 'eq', + value: { case: 'stringValue', value: INVOICE_STATES.OPEN } + }), + create(RQLFilterSchema, { + name: 'amount', + operator: 'gt', + value: { case: 'numberValue', value: 0 } + }) + ]; +} diff --git a/web/sdk/client/views/billing/components/payment-issue.tsx b/web/sdk/client/views/billing/components/payment-issue.tsx index 93dfc1e3da..a2dc72d69c 100644 --- a/web/sdk/client/views/billing/components/payment-issue.tsx +++ b/web/sdk/client/views/billing/components/payment-issue.tsx @@ -6,11 +6,11 @@ import { ExclamationTriangleIcon } from '@radix-ui/react-icons'; import { Subscription, RQLRequestSchema, - RQLFilterSchema, RQLSortSchema } from '@raystack/proton/frontier'; import { create } from '@bufbuild/protobuf'; import { INVOICE_STATES, SUBSCRIPTION_STATES } from '../../../utils/constants'; +import { openInvoiceFilters } from '../../../utils/invoice-queries'; import { DEFAULT_PAGE_SIZE } from '../../../utils/connect-pagination'; import { useOrganizationInvoices } from '../../../hooks/useOrganizationInvoices'; import styles from '../billing-view.module.css'; @@ -18,18 +18,7 @@ import styles from '../billing-view.module.css'; // Open invoices with a non-zero amount, newest first — the invoice that needs // payment when a subscription is past due. const OPEN_INVOICES_QUERY = create(RQLRequestSchema, { - filters: [ - create(RQLFilterSchema, { - name: 'state', - operator: 'eq', - value: { case: 'stringValue', value: INVOICE_STATES.OPEN } - }), - create(RQLFilterSchema, { - name: 'amount', - operator: 'gt', - value: { case: 'numberValue', value: 0 } - }) - ], + filters: openInvoiceFilters(), sort: [create(RQLSortSchema, { name: 'created_at', order: 'desc' })], offset: 0, limit: DEFAULT_PAGE_SIZE diff --git a/web/sdk/client/views/general/components/delete-organization-dialog.tsx b/web/sdk/client/views/general/components/delete-organization-dialog.tsx index 52f9bffddc..b3e8c102bb 100644 --- a/web/sdk/client/views/general/components/delete-organization-dialog.tsx +++ b/web/sdk/client/views/general/components/delete-organization-dialog.tsx @@ -20,6 +20,7 @@ import { } from '@raystack/apsara'; import { useFrontier } from '../../../contexts/FrontierContext'; import { useTerminology } from '../../../hooks/useTerminology'; +import { useTokens } from '../../../hooks/useTokens'; import { handleConnectError } from '~/utils/error'; const deleteOrgSchema = yup @@ -44,6 +45,9 @@ export const DeleteOrganizationDialog = ({ const orgLabel = t.organization({ case: 'capital' }); const orgLabelLower = t.organization({ case: 'lower' }); const [isAcknowledged, setIsAcknowledged] = useState(false); + // fetched only while the dialog is open; the confirm button waits for the + // answer so the forfeit warning cannot be skipped by a slow response + const { tokenBalance, isTokensLoading } = useTokens({ enabled: open }); const { mutateAsync: deleteOrganization } = useMutation( FrontierServiceQueries.deleteOrganization @@ -83,6 +87,7 @@ export const DeleteOrganizationDialog = ({ } catch (error) { handleConnectError(error, { PermissionDenied: () => toastManager.add({ title: "You don't have permission to perform this action", type: 'error' }), + FailedPrecondition: (err) => toastManager.add({ title: `Cannot delete this ${orgLabelLower} yet`, description: err.message, type: 'error' }), NotFound: (err) => toastManager.add({ title: 'Not found', description: err.message, type: 'error' }), Default: (err) => toastManager.add({ title: 'Something went wrong', description: err.message, type: 'error' }), }); @@ -102,6 +107,14 @@ export const DeleteOrganizationDialog = ({ This action can not be undone. This will permanently delete all the projects and resources in {organization?.title}. + {tokenBalance > 0 ? ( + + You have {tokenBalance.toString()} tokens remaining. Deleting + the {orgLabelLower} forfeits them. Please contact support + about these tokens: any amount that was purchased can be + transferred to your bank account. + + ) : null} ; +// One short instruction per kind of delete blocker the server can report. +// An unknown kind falls back to the server's own message. +const BLOCKER_INSTRUCTIONS: Record string> = { + ACTIVE_SUBSCRIPTION: () => + 'Please downgrade the subscription to the standard plan', + UNPAID_INVOICE: count => + count > 1 + ? `Please pay the ${count} open invoices from the billing page` + : 'Please pay the open invoice from the billing page', + NEGATIVE_TOKEN_BALANCE: () => + 'Please contact support to settle the token balance' +}; + export interface GeneralViewProps { onDeleteSuccess?: () => void; urlPrefix?: string; @@ -97,6 +112,35 @@ export function GeneralView({ onDeleteSuccess, urlPrefix }: GeneralViewProps = { const isLoading = !organization?.id || isActiveOrganizationLoading || isPermissionsFetching; + // the server's own answer to "would a delete go through right now" — the + // same blockers a real delete would refuse with. While it loads the button + // stays disabled rather than briefly allowing a delete the server would + // refuse; a failed check fails open since the server refuses independently + const { data: deleteCheck, isLoading: isDeleteCheckLoading } = useQuery( + FrontierServiceQueries.checkOrganizationDelete, + create(CheckOrganizationDeleteRequestSchema, { + id: organization?.id ?? '' + }), + { + enabled: canDeleteWorkspace && !!organization?.id, + retry: false + } + ); + const isDeleteBlocked = !!deleteCheck && !deleteCheck.canDelete; + const blockerLines = useMemo(() => { + const blockers = deleteCheck?.blockers ?? []; + const counts = new Map(); + for (const blocker of blockers) { + counts.set(blocker.type, (counts.get(blocker.type) ?? 0) + 1); + } + return [...counts.entries()].map( + ([type, count]) => + BLOCKER_INSTRUCTIONS[type]?.(count) ?? + blockers.find(blocker => blocker.type === type)?.message ?? + type + ); + }, [deleteCheck]); + // Update organization form const { mutateAsync: updateOrganization } = useMutation( FrontierServiceQueries.updateOrganization, @@ -280,22 +324,34 @@ export function GeneralView({ onDeleteSuccess, urlPrefix }: GeneralViewProps = { } > - {!canDeleteWorkspace && ( + {!canDeleteWorkspace ? ( {AuthTooltipMessage} - )} + ) : isDeleteBlocked ? ( + + + {blockerLines.map(line => ( + {line} + ))} + + + ) : null} )} diff --git a/web/sdk/package.json b/web/sdk/package.json index 0beca580e0..9c32fc0366 100644 --- a/web/sdk/package.json +++ b/web/sdk/package.json @@ -101,7 +101,7 @@ "@connectrpc/connect-query": "2.1.1", "@connectrpc/connect-web": "2.1.1", "@hookform/resolvers": "^3.10.0", - "@raystack/proton": "0.1.0-f0b06e61f1985a9052f32744976a2b73d8c56839", + "@raystack/proton": "0.1.0-194685ed0280d282261bc16f708266465dd1deb1", "@tanstack/react-query": "^5.90.2", "@tanstack/react-router": "^1.168.3", "axios": "^1.9.0",