Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 11 additions & 8 deletions web/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

15 changes: 13 additions & 2 deletions web/sdk/client/hooks/useTokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,25 @@ import { toastManager } from '@raystack/apsara';
interface UseTokensReturn {
tokenBalance: bigint;
isTokensLoading: boolean;
// unlike isTokensLoading, this is also true while an already cached
// balance is being refetched
isTokensFetching: boolean;
fetchTokenBalance: () => Promise<any>;
}

export const useTokens = (): UseTokensReturn => {
export interface UseTokensOptions {
// Set this to false to skip fetching the balance. The delete dialog uses
// it so the balance is only fetched while the dialog is open.
enabled?: boolean;
}

export const useTokens = (options: UseTokensOptions = {}): UseTokensReturn => {
const { billingAccount } = useFrontier();

const {
data,
isLoading: isTokensLoading,
isFetching: isTokensFetching,
error,
refetch
} = useQuery(
Expand All @@ -25,7 +35,7 @@ export const useTokens = (): UseTokensReturn => {
id: billingAccount?.id ?? ''
}),
{
enabled: !!billingAccount?.id,
enabled: !!billingAccount?.id && (options.enabled ?? true),
retry: false
}
);
Expand All @@ -49,6 +59,7 @@ export const useTokens = (): UseTokensReturn => {
return {
tokenBalance,
isTokensLoading,
isTokensFetching,
fetchTokenBalance: refetch
};
};
82 changes: 82 additions & 0 deletions web/sdk/client/utils/delete-blockers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { ConnectError } from '@connectrpc/connect';

// The server reports why an organization cannot be deleted as a list of
// blockers, each with a machine-readable type. These are the types the
// server knows today, mapped to a short instruction the user can act on.
// The wording must stay in line with the server behavior: a paid
// subscription must be downgraded, unpaid invoices must be paid, and a
// token debt is settled through support.
const BLOCKER_INSTRUCTIONS: Record<string, (count: number) => 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: () =>
'Your token balance is negative. Please add tokens from the Tokens page to bring it back up, or pay the invoice when it arrives, and then you can delete'
};

// Shown when the server reports a blocker kind this version does not know,
// or when the error could not be read at all.
export const GENERIC_DELETE_BLOCKED_MESSAGE =
'Something is blocking the delete right now. Please try again later or contact support.';

// instructionLines turns a list of blockers into one instruction per kind
// of blocker. Blockers of the same kind are counted so the instruction can
// say "the 2 open invoices". A kind without a known instruction becomes the
// generic message, once.
export function instructionLines(blockers: { type: string }[]): string[] {
const counts = new Map<string, number>();
for (const blocker of blockers) {
counts.set(blocker.type, (counts.get(blocker.type) ?? 0) + 1);
}
const lines: string[] = [];
let hasUnknown = false;
for (const [type, count] of counts) {
const instruction = BLOCKER_INSTRUCTIONS[type];
if (instruction) {
lines.push(instruction(count));
} else {
hasUnknown = true;
}
}
if (hasUnknown) {
lines.push(GENERIC_DELETE_BLOCKED_MESSAGE);
}
return lines;
}

// deleteBlockedDescription reads the blockers out of a failed_precondition
// error from DeleteOrganization and returns the instructions as one string.
// The server attaches them as a google.rpc.PreconditionFailure detail; over
// the Connect JSON protocol that detail arrives with a ready-made JSON copy
// in its debug field. When the error carries nothing readable, the generic
// message is returned, so the raw server text is never shown.
export function deleteBlockedDescription(err: ConnectError): string {
for (const detail of err.details) {
if (!('type' in detail) || detail.type !== 'google.rpc.PreconditionFailure') {
continue;
}
const debug = (detail as { debug?: unknown }).debug;
if (typeof debug !== 'object' || debug === null) {
continue;
}
const violations = (debug as { violations?: unknown }).violations;
if (!Array.isArray(violations)) {
continue;
}
const blockers = violations
.filter(
(violation): violation is { type: string } =>
typeof violation === 'object' &&
violation !== null &&
typeof (violation as { type?: unknown }).type === 'string'
);
const lines = instructionLines(blockers);
if (lines.length > 0) {
return lines.join('. ');
}
}
return GENERIC_DELETE_BLOCKED_MESSAGE;
}
21 changes: 21 additions & 0 deletions web/sdk/client/utils/invoice-queries.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { create } from '@bufbuild/protobuf';
import { RQLFilterSchema } from '@raystack/proton/frontier';
import { INVOICE_STATES } from './constants';

// An invoice the customer still has to pay: open state with a non-zero
// amount. This is defined once here so every caller means the same thing
// by it.
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 }
})
];
}
15 changes: 2 additions & 13 deletions web/sdk/client/views/billing/components/payment-issue.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,30 +6,19 @@ 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';

// 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ import {
} from '@raystack/apsara';
import { useFrontier } from '../../../contexts/FrontierContext';
import { useTerminology } from '../../../hooks/useTerminology';
import { useTokens } from '../../../hooks/useTokens';
import { deleteBlockedDescription } from '../../../utils/delete-blockers';
import { handleConnectError } from '~/utils/error';

const deleteOrgSchema = yup
Expand All @@ -44,6 +46,10 @@ export const DeleteOrganizationDialog = ({
const orgLabel = t.organization({ case: 'capital' });
const orgLabelLower = t.organization({ case: 'lower' });
const [isAcknowledged, setIsAcknowledged] = useState(false);
// The balance is fetched only while the dialog is open. The confirm
// button below stays disabled until the fetch finishes, so the user
// cannot confirm before the token warning had a chance to appear.
const { tokenBalance, isTokensFetching } = useTokens({ enabled: open });

const { mutateAsync: deleteOrganization } = useMutation(
FrontierServiceQueries.deleteOrganization
Expand Down Expand Up @@ -83,8 +89,11 @@ export const DeleteOrganizationDialog = ({
} catch (error) {
handleConnectError(error, {
PermissionDenied: () => toastManager.add({ title: "You don't have permission to perform this action", 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' }),
// the server names what blocks the delete; show the matching
// instructions instead of the raw server text
FailedPrecondition: (err) => toastManager.add({ title: `Cannot delete this ${orgLabelLower} yet`, description: deleteBlockedDescription(err), type: 'error' }),
NotFound: () => toastManager.add({ title: 'Not found', description: `This ${orgLabelLower} no longer exists.`, type: 'error' }),
Default: () => toastManager.add({ title: 'Something went wrong', description: 'Please try again later or contact support.', type: 'error' }),
});
}
}
Expand All @@ -102,6 +111,13 @@ export const DeleteOrganizationDialog = ({
This action can not be undone. This will permanently
delete all the projects and resources in {organization?.title}.
</Text>
{tokenBalance > 0 ? (
<Text size="small" variant="danger">
This {orgLabelLower} still has unused tokens, and deleting
it forfeits them. If any of them were purchased, our
support team will reach out to you to settle them.
</Text>
) : null}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
<Field
label={`Please type name of the ${orgLabel} to confirm.`}
error={
Expand Down Expand Up @@ -146,7 +162,12 @@ export const DeleteOrganizationDialog = ({
variant="solid"
color="danger"
type="submit"
disabled={!deleteTitle || !isAcknowledged}
disabled={
!deleteTitle ||
!isAcknowledged ||
isSubmitting ||
isTokensFetching
}
data-test-id="frontier-sdk-delete-organization-btn"
loading={isSubmitting}
loaderText="Deleting..."
Expand Down
Loading
Loading