fix: add early quota warning (#2986)

## Description

- Add banners when plans near fair use limits
- Automated email alerts to our support when nearing fair use limits
This commit is contained in:
David Nguyen
2026-06-15 16:02:18 +10:00
committed by GitHub
parent eb45d1e5a9
commit 15549a6758
12 changed files with 550 additions and 198 deletions
@@ -3,6 +3,7 @@ import { SUPPORT_EMAIL } from '@documenso/lib/constants/app';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION, SKIP_QUERY_BATCH_META } from '@documenso/lib/constants/trpc';
import { INTERNAL_CLAIM_ID } from '@documenso/lib/types/subscription';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import {
@@ -38,12 +39,18 @@ export const OrganisationQuotaBanner = () => {
quotaFlags?.isDocumentQuotaExceeded || quotaFlags?.isEmailQuotaExceeded || quotaFlags?.isApiQuotaExceeded,
);
// Every member of the organisation sees the banner when a quota is exhausted.
const isAnyQuotaNearing = Boolean(
quotaFlags?.isDocumentQuotaNearing || quotaFlags?.isEmailQuotaNearing || quotaFlags?.isApiQuotaNearing,
);
// Every member of the organisation sees the banner when a quota is exhausted or
// nearing its limit. When both states apply, "exceeded" wins for the banner copy
// and the dialog lists both exceeded and nearing items.
// Note: Skipping free plan banner for now because their quota can incorrectly show as exceeded.
if (
!organisation ||
!quotaFlags ||
!isAnyQuotaExceeded ||
(!isAnyQuotaExceeded && !isAnyQuotaNearing) ||
organisation.organisationClaim.originalSubscriptionClaimId === INTERNAL_CLAIM_ID.FREE
) {
return null;
@@ -51,17 +58,29 @@ export const OrganisationQuotaBanner = () => {
return (
<>
<div className="bg-yellow-200 text-yellow-900 dark:bg-yellow-400">
<div
className={cn({
'bg-yellow-200 text-yellow-900 dark:bg-yellow-400': !isAnyQuotaExceeded,
'bg-destructive text-destructive-foreground': isAnyQuotaExceeded,
})}
>
<div className="mx-auto flex max-w-screen-xl items-center justify-center gap-x-4 px-4 py-2 font-medium text-sm">
<div className="flex items-center">
<AlertTriangle className="mr-2.5 h-5 w-5" />
<Trans>Your organisation has exceeded a fair use limit</Trans>
{isAnyQuotaExceeded ? (
<Trans>Your organisation has exceeded a fair use limit</Trans>
) : (
<Trans>Your organisation is approaching a fair use limit</Trans>
)}
</div>
<Button
variant="outline"
className="text-yellow-900 hover:bg-yellow-100 dark:hover:bg-yellow-500"
className={cn({
'text-yellow-900 hover:bg-yellow-100 dark:hover:bg-yellow-500': !isAnyQuotaExceeded,
'text-destructive-foreground hover:bg-destructive hover:text-white': isAnyQuotaExceeded,
})}
onClick={() => setIsOpen(true)}
size="sm"
>
@@ -74,17 +93,27 @@ export const OrganisationQuotaBanner = () => {
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans>Fair use limit exceeded</Trans>
{isAnyQuotaExceeded ? <Trans>Fair use limit exceeded</Trans> : <Trans>Approaching fair use limit</Trans>}
</DialogTitle>
<DialogDescription>
<Trans>
Your organisation has exceeded a fair use limit. Please contact{' '}
<a className="text-primary" href={`mailto:${SUPPORT_EMAIL}`}>
support
</a>{' '}
to review your plan's limits.
</Trans>
{isAnyQuotaExceeded ? (
<Trans>
Your organisation has exceeded a fair use limit. Please contact{' '}
<a className="text-primary" href={`mailto:${SUPPORT_EMAIL}`}>
support
</a>{' '}
to review your plan's limits.
</Trans>
) : (
<Trans>
Your organisation is approaching a fair use limit. If you expect to need higher limits, please contact{' '}
<a className="text-primary" href={`mailto:${SUPPORT_EMAIL}`}>
support
</a>{' '}
to review your plan's limits.
</Trans>
)}
</DialogDescription>
</DialogHeader>
@@ -106,6 +135,21 @@ export const OrganisationQuotaBanner = () => {
<Trans>API requests have been temporarily paused</Trans>
</li>
)}
{quotaFlags.isDocumentQuotaNearing && (
<li className="list-disc">
<Trans>Document usage is approaching fair use limits</Trans>
</li>
)}
{quotaFlags.isEmailQuotaNearing && (
<li className="list-disc">
<Trans>Email usage is approaching fair use limits</Trans>
</li>
)}
{quotaFlags.isApiQuotaNearing && (
<li className="list-disc">
<Trans>API usage is approaching fair use limits</Trans>
</li>
)}
</ul>
</AlertDescription>
</Alert>
@@ -0,0 +1,169 @@
import { INTERNAL_CLAIM_ID } from '@documenso/lib/types/subscription';
import { generateDatabaseId } from '@documenso/lib/universal/id';
import { currentMonthlyPeriod } from '@documenso/lib/universal/monthly-period';
import { prisma } from '@documenso/prisma';
import { seedUser } from '@documenso/prisma/seed/users';
import { expect, test } from '@playwright/test';
import { apiSignin } from '../fixtures/authentication';
const BANNER_EXCEEDED_TEXT = 'Your organisation has exceeded a fair use limit';
const BANNER_NEARING_TEXT = 'Your organisation is approaching a fair use limit';
type SeedQuotaStateOptions = {
organisationId: string;
organisationClaimId: string;
/**
* The `originalSubscriptionClaimId` to set on the claim. The banner is suppressed
* for `INTERNAL_CLAIM_ID.FREE`, so use a non-free value to make it render.
*/
subscriptionClaimId: string;
documentQuota: number | null;
documentCount: number;
};
/**
* Point the organisation's document quota and current-period usage at a known
* state. Only the document counter is touched; email/api quotas stay `null`
* (unlimited) so the document counter is the sole driver of the banner.
*/
const seedQuotaState = async ({
organisationId,
organisationClaimId,
subscriptionClaimId,
documentQuota,
documentCount,
}: SeedQuotaStateOptions) => {
await prisma.organisationClaim.update({
where: {
id: organisationClaimId,
},
data: {
originalSubscriptionClaimId: subscriptionClaimId,
documentQuota,
},
});
const period = currentMonthlyPeriod();
await prisma.organisationMonthlyStat.upsert({
where: {
organisationId_period: {
organisationId,
period,
},
},
update: {
documentCount,
},
create: {
id: generateDatabaseId('org_monthly_stat'),
organisationId,
period,
documentCount,
},
});
};
test('[QUOTA BANNER]: shows the approaching state when a quota is nearing', async ({ page }) => {
const { user, organisation } = await seedUser({
isPersonalOrganisation: false,
});
// ceil(10 * 0.8) = 8 → nearing, but not yet exceeded.
await seedQuotaState({
organisationId: organisation.id,
organisationClaimId: organisation.organisationClaim.id,
subscriptionClaimId: INTERNAL_CLAIM_ID.TEAM,
documentQuota: 10,
documentCount: 8,
});
await apiSignin({
page,
email: user.email,
redirectPath: `/o/${organisation.url}/settings/general`,
});
await expect(page.getByText(BANNER_NEARING_TEXT)).toBeVisible();
await expect(page.getByText(BANNER_EXCEEDED_TEXT)).toBeHidden();
});
test('[QUOTA BANNER]: shows the exceeded state when a quota is reached', async ({ page }) => {
const { user, organisation } = await seedUser({
isPersonalOrganisation: false,
});
// usage >= quota → exceeded.
await seedQuotaState({
organisationId: organisation.id,
organisationClaimId: organisation.organisationClaim.id,
subscriptionClaimId: INTERNAL_CLAIM_ID.TEAM,
documentQuota: 10,
documentCount: 10,
});
await apiSignin({
page,
email: user.email,
redirectPath: `/o/${organisation.url}/settings/general`,
});
await expect(page.getByText(BANNER_EXCEEDED_TEXT)).toBeVisible();
await expect(page.getByText(BANNER_NEARING_TEXT)).toBeHidden();
});
test('[QUOTA BANNER]: learn more dialog lists the affected counter', async ({ page }) => {
const { user, organisation } = await seedUser({
isPersonalOrganisation: false,
});
await seedQuotaState({
organisationId: organisation.id,
organisationClaimId: organisation.organisationClaim.id,
subscriptionClaimId: INTERNAL_CLAIM_ID.TEAM,
documentQuota: 10,
documentCount: 10,
});
await apiSignin({
page,
email: user.email,
redirectPath: `/o/${organisation.url}/settings/general`,
});
await page.getByRole('button', { name: 'Learn more' }).click();
const dialog = page.getByRole('dialog');
await expect(dialog.getByText('Fair use limit exceeded')).toBeVisible();
await expect(dialog.getByText('Document creation has been temporarily paused')).toBeVisible();
await expect(dialog.getByRole('link', { name: 'support' })).toHaveAttribute('href', /^mailto:/);
});
test('[QUOTA BANNER]: is hidden for free-claim organisations', async ({ page }) => {
const { user, organisation } = await seedUser({
isPersonalOrganisation: false,
});
// Usage is exceeded, but a free-claim organisation must never see the banner.
await seedQuotaState({
organisationId: organisation.id,
organisationClaimId: organisation.organisationClaim.id,
subscriptionClaimId: INTERNAL_CLAIM_ID.FREE,
documentQuota: 10,
documentCount: 10,
});
await apiSignin({
page,
email: user.email,
redirectPath: `/o/${organisation.url}/settings/general`,
});
// Anchor on a stable element so banner-absence is meaningful (page fully loaded).
await expect(page.getByLabel('Organisation Name*')).toBeVisible();
await expect(page.getByText(BANNER_EXCEEDED_TEXT)).toBeHidden();
await expect(page.getByRole('button', { name: 'Learn more' })).toBeHidden();
});
@@ -0,0 +1,152 @@
import { SUPPORT_EMAIL } from '@documenso/lib/constants/app';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { match } from 'ts-pattern';
import { Body, Container, Head, Hr, Html, Preview, Section, Text } from '../components';
import { TemplateBrandingLogo } from '../template-components/template-branding-logo';
import { TemplateFooter } from '../template-components/template-footer';
export type OrganisationLimitAlertEmailProps = {
assetBaseUrl: string;
organisationName: string;
counter: 'document' | 'email' | 'api';
kind: 'rateLimit' | 'quota' | 'quotaNearing';
period: string;
};
export const OrganisationLimitAlertEmailTemplate = ({
assetBaseUrl = 'http://localhost:3002',
organisationName = 'Organisation Name',
counter = 'email',
kind = 'quota',
period = '2026-05',
}: OrganisationLimitAlertEmailProps) => {
const { _ } = useLingui();
const previewText = kind === 'quotaNearing' ? msg`Approaching Your Plan Limits` : msg`Organisation Review Required`;
return (
<Html>
<Head />
<Preview>{_(previewText)}</Preview>
<Body className="mx-auto my-auto font-sans">
<Section className="bg-white text-slate-500">
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-slate-200 border-solid p-2 backdrop-blur-sm">
<TemplateBrandingLogo assetBaseUrl={assetBaseUrl} className="mb-4 h-6 p-2" />
<Section className="p-2 text-slate-500">
<Text className="text-center font-medium text-black text-lg">
{kind === 'quotaNearing' ? (
<Trans>Approaching Your Plan Limits</Trans>
) : (
<Trans>Organisation Review Required</Trans>
)}
</Text>
<div className="mx-auto my-2 w-fit rounded-lg bg-gray-50 px-4 py-2 font-medium text-base text-slate-600">
{organisationName}
</div>
{match(kind)
.with('quota', () => (
<Text className="text-center text-base">
{match(counter)
.with('document', () => (
<Trans>
We've noticed document activity on your account that exceeds the fair use limits of your
current plan. As a precaution, new document activity has been temporarily paused pending
review.
</Trans>
))
.with('email', () => (
<Trans>
We've noticed email sending activity on your account that exceeds the fair use limits of your
current plan. As a precaution, new email activity has been temporarily paused pending review.
</Trans>
))
.with('api', () => (
<Trans>
We've noticed API activity on your account that exceeds the fair use limits of your current
plan. As a precaution, new API activity has been temporarily paused pending review.
</Trans>
))
.exhaustive()}
</Text>
))
.with('rateLimit', () => (
<Text className="text-center text-base">
{match(counter)
.with('document', () => (
<Trans>
Your organisation is generating documents faster than normal, so some requests are being
temporarily throttled.
</Trans>
))
.with('email', () => (
<Trans>
Your organisation is generating emails faster than normal, so some requests are being
temporarily throttled.
</Trans>
))
.with('api', () => (
<Trans>
Your organisation is generating API requests faster than normal, so some requests are being
temporarily throttled.
</Trans>
))
.exhaustive()}
</Text>
))
.with('quotaNearing', () => (
<Text className="text-center text-base">
{match(counter)
.with('document', () => (
<Trans>
Your organisation is nearing its fair use limits for creating documents on your current plan.
Once the limit is reached, new document activity will be temporarily paused.
</Trans>
))
.with('email', () => (
<Trans>
Your organisation is nearing its fair use limits for sending email on your current plan. Once
the limit is reached, new email activity will be temporarily paused.
</Trans>
))
.with('api', () => (
<Trans>
Your organisation is nearing its fair use limits for making API requests on your current plan.
Once the limit is reached, new API activity will be temporarily paused.
</Trans>
))
.exhaustive()}
</Text>
))
.exhaustive()}
<Text className="text-center text-base">
{kind === 'quotaNearing' ? (
<Trans>
If you expect to need higher limits, please contact support at {SUPPORT_EMAIL} and we will review
your account.
</Trans>
) : (
<Trans>Please contact support at {SUPPORT_EMAIL} and we will review your account.</Trans>
)}
</Text>
</Section>
</Container>
<Hr className="mx-auto mt-12 max-w-xl" />
<Container className="mx-auto max-w-xl">
<TemplateFooter isDocument={false} />
</Container>
</Section>
</Body>
</Html>
);
};
export default OrganisationLimitAlertEmailTemplate;
@@ -1,109 +0,0 @@
import { SUPPORT_EMAIL } from '@documenso/lib/constants/app';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { match } from 'ts-pattern';
import { Body, Container, Head, Hr, Html, Preview, Section, Text } from '../components';
import { TemplateBrandingLogo } from '../template-components/template-branding-logo';
import { TemplateFooter } from '../template-components/template-footer';
export type OrganisationLimitExceededEmailProps = {
assetBaseUrl: string;
organisationName: string;
counter: 'document' | 'email' | 'api';
kind: 'rateLimit' | 'quota';
period: string;
};
export const OrganisationLimitExceededEmailTemplate = ({
assetBaseUrl = 'http://localhost:3002',
organisationName = 'Organisation Name',
counter = 'email',
kind = 'quota',
period = '2026-05',
}: OrganisationLimitExceededEmailProps) => {
const { _ } = useLingui();
const previewText = msg`Organisation Review Required`;
return (
<Html>
<Head />
<Preview>{_(previewText)}</Preview>
<Body className="mx-auto my-auto font-sans">
<Section className="bg-white text-slate-500">
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-slate-200 border-solid p-2 backdrop-blur-sm">
<TemplateBrandingLogo assetBaseUrl={assetBaseUrl} className="mb-4 h-6 p-2" />
<Section className="p-2 text-slate-500">
<Text className="text-center font-medium text-black text-lg">
<Trans>Organisation Review Required</Trans>
</Text>
{kind === 'quota' ? (
<Text className="text-center text-base">
{match(counter)
.with('document', () => (
<Trans>
We've noticed document activity on your account that exceeds the fair use limits of your current
plan. As a precaution, new document activity has been temporarily paused pending review.
</Trans>
))
.with('email', () => (
<Trans>
We've noticed email sending activity on your account that exceeds the fair use limits of your
current plan. As a precaution, new email activity has been temporarily paused pending review.
</Trans>
))
.with('api', () => (
<Trans>
We've noticed API activity on your account that exceeds the fair use limits of your current
plan. As a precaution, new API activity has been temporarily paused pending review.
</Trans>
))
.exhaustive()}
</Text>
) : (
<Text className="text-center text-base">
{match(counter)
.with('document', () => (
<Trans>
Your organisation is generating documents faster than normal, so some requests are being
temporarily throttled.
</Trans>
))
.with('email', () => (
<Trans>
Your organisation is generating emails faster than normal, so some requests are being
temporarily throttled.
</Trans>
))
.with('api', () => (
<Trans>
Your organisation is generating API requests faster than normal, so some requests are being
temporarily throttled.
</Trans>
))
.exhaustive()}
</Text>
)}
<Text className="text-center text-base">
<Trans>Please contact support at {SUPPORT_EMAIL} and we will review your account.</Trans>
</Text>
</Section>
</Container>
<Hr className="mx-auto mt-12 max-w-xl" />
<Container className="mx-auto max-w-xl">
<TemplateFooter isDocument={false} />
</Container>
</Section>
</Body>
</Html>
);
};
export default OrganisationLimitExceededEmailTemplate;
+2 -2
View File
@@ -4,7 +4,7 @@ import { SEND_CONFIRMATION_EMAIL_JOB_DEFINITION } from './definitions/emails/sen
import { SEND_DOCUMENT_CANCELLED_EMAILS_JOB_DEFINITION } from './definitions/emails/send-document-cancelled-emails';
import { SEND_DOCUMENT_COMPLETED_EMAILS_JOB_DEFINITION } from './definitions/emails/send-document-completed-emails';
import { SEND_DOCUMENT_CREATED_FROM_DIRECT_TEMPLATE_EMAIL_JOB_DEFINITION } from './definitions/emails/send-document-created-from-direct-template-email';
import { SEND_ORGANISATION_LIMIT_EXCEEDED_EMAIL_JOB_DEFINITION } from './definitions/emails/send-organisation-limit-exceeded-email';
import { SEND_ORGANISATION_LIMIT_ALERT_EMAIL_JOB_DEFINITION } from './definitions/emails/send-organisation-limit-alert-email';
import { SEND_ORGANISATION_MEMBER_JOINED_EMAIL_JOB_DEFINITION } from './definitions/emails/send-organisation-member-joined-email';
import { SEND_ORGANISATION_MEMBER_LEFT_EMAIL_JOB_DEFINITION } from './definitions/emails/send-organisation-member-left-email';
import { SEND_OWNER_RECIPIENT_EXPIRED_EMAIL_JOB_DEFINITION } from './definitions/emails/send-owner-recipient-expired-email';
@@ -37,7 +37,7 @@ export const jobsClient = new JobClient([
SEND_CONFIRMATION_EMAIL_JOB_DEFINITION,
SEND_ORGANISATION_MEMBER_JOINED_EMAIL_JOB_DEFINITION,
SEND_ORGANISATION_MEMBER_LEFT_EMAIL_JOB_DEFINITION,
SEND_ORGANISATION_LIMIT_EXCEEDED_EMAIL_JOB_DEFINITION,
SEND_ORGANISATION_LIMIT_ALERT_EMAIL_JOB_DEFINITION,
SEND_TEAM_DELETED_EMAIL_JOB_DEFINITION,
SEAL_DOCUMENT_JOB_DEFINITION,
SEAL_DOCUMENT_SWEEP_JOB_DEFINITION,
@@ -1,21 +1,25 @@
import OrganisationLimitExceededEmailTemplate from '@documenso/email/templates/organisation-limit-exceeded';
import { mailer } from '@documenso/email/mailer';
import OrganisationLimitAlertEmailTemplate from '@documenso/email/templates/organisation-limit-alert';
import { SUPPORT_EMAIL } from '@documenso/lib/constants/app';
import { prisma } from '@documenso/prisma';
import { msg } from '@lingui/core/macro';
import { createElement } from 'react';
import { getI18nInstance } from '../../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
import { DOCUMENSO_INTERNAL_EMAIL } from '../../../constants/email';
import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '../../../constants/organisations';
import { getEmailContext } from '../../../server-only/email/get-email-context';
import { INTERNAL_CLAIM_ID } from '../../../types/subscription';
import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n';
import type { JobRunIO } from '../../client/_internal/job';
import type { TSendOrganisationLimitExceededEmailJobDefinition } from './send-organisation-limit-exceeded-email';
import type { TSendOrganisationLimitAlertEmailJobDefinition } from './send-organisation-limit-alert-email';
export const run = async ({
payload,
io,
}: {
payload: TSendOrganisationLimitExceededEmailJobDefinition;
payload: TSendOrganisationLimitAlertEmailJobDefinition;
io: JobRunIO;
}) => {
const organisation = await prisma.organisation.findFirstOrThrow({
@@ -24,6 +28,16 @@ export const run = async ({
},
include: {
organisationClaim: true,
monthlyStats: {
where: {
period: payload.period,
},
select: {
documentCount: true,
emailCount: true,
apiCount: true,
},
},
members: {
where: {
organisationGroupMembers: {
@@ -60,16 +74,19 @@ export const run = async ({
// Do not send emails for "free" claims.
if (organisation.organisationClaim.originalSubscriptionClaimId === INTERNAL_CLAIM_ID.FREE) {
io.logger.info({
msg: 'Skipping organisation limit exceeded email for "free" claim',
msg: 'Skipping organisation limit alert email for "free" claim',
organisationId: organisation.id,
});
return;
}
const memberSubject =
payload.kind === 'quotaNearing' ? msg`Approaching Your Plan Limits` : msg`Organisation Review Required`;
for (const member of organisation.members) {
await io.runTask(`send-organisation-limit-exceeded-email-${member.id}`, async () => {
const emailContent = createElement(OrganisationLimitExceededEmailTemplate, {
await io.runTask(`send-organisation-limit-alert-email-${member.id}`, async () => {
const emailContent = createElement(OrganisationLimitAlertEmailTemplate, {
assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL(),
organisationName: organisation.name,
counter: payload.counter,
@@ -87,7 +104,7 @@ export const run = async ({
await emailTransport.sendMail({
to: member.user.email,
from: senderEmail,
subject: i18n._(msg`Organisation Review Required`),
subject: i18n._(memberSubject),
html,
text,
});
@@ -95,20 +112,31 @@ export const run = async ({
}
// Todo: Logging
// Todo: Decide if we want to send an email or alert via another software.
// const i18n = await getI18nInstance('en');
const i18n = await getI18nInstance('en');
// // Email our support team.
// await mailer.sendMail({
// to: SUPPORT_EMAIL,
// from: senderEmail,
// subject: i18n._(msg`An organisation has exceeded their fair use limits`),
// text: `
// Organisation: ${organisation.name}
// Organisation ID: ${organisation.id}
// Counter: ${payload.counter}
// Kind: ${payload.kind}
// Period: ${payload.period}
// `,
// });
const supportSubject =
payload.kind === 'quotaNearing'
? msg`An organisation is nearing their fair use limits`
: msg`An organisation has exceeded their fair use limits`;
// Email our support team. Purposefully sent from the internal email since the
// global mailer is not authorized to send from custom per-plan transport addresses.
await io.runTask('send-organisation-limit-alert-support-email', async () => {
await mailer.sendMail({
to: SUPPORT_EMAIL,
from: DOCUMENSO_INTERNAL_EMAIL,
subject: i18n._(supportSubject),
text: `
Organisation: ${organisation.name}
Organisation ID: ${organisation.id}
Organisation Claim Original ID: ${organisation.organisationClaim.originalSubscriptionClaimId}
Email Quota: ${organisation.monthlyStats[0]?.emailCount || 0}/${organisation.organisationClaim.emailQuota ?? 'Unlimited'}
API Quota: ${organisation.monthlyStats[0]?.apiCount || 0}/${organisation.organisationClaim.apiQuota ?? 'Unlimited'}
Document Quota: ${organisation.monthlyStats[0]?.documentCount || 0}/${organisation.organisationClaim.documentQuota ?? 'Unlimited'}
Counter: ${payload.counter}
Kind: ${payload.kind}
Period: ${payload.period}
`,
});
});
};
@@ -0,0 +1,34 @@
import { z } from 'zod';
import type { JobDefinition } from '../../client/_internal/job';
const SEND_ORGANISATION_LIMIT_ALERT_EMAIL_JOB_DEFINITION_ID = 'send.organisation-limit-alert.email';
const SEND_ORGANISATION_LIMIT_ALERT_EMAIL_JOB_DEFINITION_SCHEMA = z.object({
organisationId: z.string(),
counter: z.enum(['document', 'email', 'api']),
kind: z.enum(['rateLimit', 'quota', 'quotaNearing']),
period: z.string(),
});
export type TSendOrganisationLimitAlertEmailJobDefinition = z.infer<
typeof SEND_ORGANISATION_LIMIT_ALERT_EMAIL_JOB_DEFINITION_SCHEMA
>;
export const SEND_ORGANISATION_LIMIT_ALERT_EMAIL_JOB_DEFINITION = {
id: SEND_ORGANISATION_LIMIT_ALERT_EMAIL_JOB_DEFINITION_ID,
name: 'Send Organisation Limit Alert Email',
version: '1.0.0',
trigger: {
name: SEND_ORGANISATION_LIMIT_ALERT_EMAIL_JOB_DEFINITION_ID,
schema: SEND_ORGANISATION_LIMIT_ALERT_EMAIL_JOB_DEFINITION_SCHEMA,
},
handler: async ({ payload, io }) => {
const handler = await import('./send-organisation-limit-alert-email.handler');
await handler.run({ payload, io });
},
} as const satisfies JobDefinition<
typeof SEND_ORGANISATION_LIMIT_ALERT_EMAIL_JOB_DEFINITION_ID,
TSendOrganisationLimitAlertEmailJobDefinition
>;
@@ -1,34 +0,0 @@
import { z } from 'zod';
import type { JobDefinition } from '../../client/_internal/job';
const SEND_ORGANISATION_LIMIT_EXCEEDED_EMAIL_JOB_DEFINITION_ID = 'send.organisation-limit-exceeded.email';
const SEND_ORGANISATION_LIMIT_EXCEEDED_EMAIL_JOB_DEFINITION_SCHEMA = z.object({
organisationId: z.string(),
counter: z.enum(['document', 'email', 'api']),
kind: z.enum(['rateLimit', 'quota']),
period: z.string(),
});
export type TSendOrganisationLimitExceededEmailJobDefinition = z.infer<
typeof SEND_ORGANISATION_LIMIT_EXCEEDED_EMAIL_JOB_DEFINITION_SCHEMA
>;
export const SEND_ORGANISATION_LIMIT_EXCEEDED_EMAIL_JOB_DEFINITION = {
id: SEND_ORGANISATION_LIMIT_EXCEEDED_EMAIL_JOB_DEFINITION_ID,
name: 'Send Organisation Limit Exceeded Email',
version: '1.0.0',
trigger: {
name: SEND_ORGANISATION_LIMIT_EXCEEDED_EMAIL_JOB_DEFINITION_ID,
schema: SEND_ORGANISATION_LIMIT_EXCEEDED_EMAIL_JOB_DEFINITION_SCHEMA,
},
handler: async ({ payload, io }) => {
const handler = await import('./send-organisation-limit-exceeded-email.handler');
await handler.run({ payload, io });
},
} as const satisfies JobDefinition<
typeof SEND_ORGANISATION_LIMIT_EXCEEDED_EMAIL_JOB_DEFINITION_ID,
TSendOrganisationLimitExceededEmailJobDefinition
>;
@@ -3,6 +3,7 @@ import { AppError, AppErrorCode } from '../../errors/app-error';
import { jobsClient } from '../../jobs/client';
import { generateDatabaseId } from '../../universal/id';
import { currentMonthlyPeriod } from '../../universal/monthly-period';
import { getQuotaAlertKind } from './get-quota-alert-kind';
import type { LimitCounter } from './types';
type CheckMonthlyQuotaOptions = {
@@ -56,29 +57,33 @@ export const checkMonthlyQuota = async (opts: CheckMonthlyQuotaOptions): Promise
const newCount = latestMonthlyStat[column];
const previousCount = newCount - opts.count;
const isOverQuota = newCount > opts.quota;
// Returns 'quota' on the single request that reached (or jumped past) the quota,
// 'quotaNearing' on the single request that reached the warning threshold,
// otherwise null. See getQuotaAlertKind for the exactly-once guarantee.
const alertKind = getQuotaAlertKind({
previousCount,
newCount,
quota: opts.quota,
});
// Only notify on the single request that crossed the threshold: the count was
// at/under quota before this request and over it after. Because the DB
// serializes the atomic increment, the post-increment values are distinct and
// monotonic, so exactly one request's (previousCount, newCount] interval
// contains the quota boundary — guaranteeing the notification fires once.
const didCrossQuota = isOverQuota && previousCount <= opts.quota;
if (didCrossQuota) {
// Trigger the alert before the over-quota check — the 'quota' alert usually fires
// on the successful request that consumes the last unit of allowance, but when a
// batch jumps past the boundary it fires on this rejected request. Either way it
// will never fire again this period, so it must be enqueued before any throw.
if (alertKind) {
await jobsClient
.triggerJob({
name: 'send.organisation-limit-exceeded.email',
name: 'send.organisation-limit-alert.email',
payload: {
organisationId: opts.organisationId,
counter: opts.counter,
kind: 'quota',
kind: alertKind,
period,
},
})
.catch((error) => {
console.error({
msg: 'Failed to send organisation limit exceeded email',
msg: 'Failed to send organisation limit alert email',
error,
});
@@ -86,7 +91,7 @@ export const checkMonthlyQuota = async (opts: CheckMonthlyQuotaOptions): Promise
});
}
if (isOverQuota) {
if (newCount > opts.quota) {
throw new AppError(AppErrorCode.TOO_MANY_REQUESTS, {
message:
'Your request could not be completed at this time due to your account exceeding the fair use limits of your current plan. Please contact support.',
@@ -1,7 +1,12 @@
import { QUOTA_WARNING_THRESHOLD } from './get-quota-alert-kind';
export type QuotaFlags = {
isDocumentQuotaExceeded: boolean;
isEmailQuotaExceeded: boolean;
isApiQuotaExceeded: boolean;
isDocumentQuotaNearing: boolean;
isEmailQuotaNearing: boolean;
isApiQuotaNearing: boolean;
};
type ComputeQuotaFlagsOptions = {
@@ -20,11 +25,6 @@ type ComputeQuotaFlagsOptions = {
/**
* A quota of `null` means unlimited (never exceeded). A quota of `0` means
* blocked (always exceeded). Otherwise usage `>=` quota is exceeded.
*
* Note: this `>=` is intentionally the "reached" signal for the banner and is
* distinct from enforcement in `check-monthly-quota.ts`, which blocks the
* action that crosses the boundary using a strict `>` on the post-increment
* count. Do not "align" them — they answer different questions.
*/
const isQuotaExceeded = (quota: number | null, usage: number): boolean => {
if (quota === null) {
@@ -38,10 +38,30 @@ const isQuotaExceeded = (quota: number | null, usage: number): boolean => {
return usage >= quota;
};
/**
* A counter is "nearing" its quota once usage reaches the warning threshold
* (80% of the quota, rounded up) but has not yet been exceeded. Nearing and
* exceeded are mutually exclusive per counter.
*/
const isQuotaNearing = (quota: number | null, usage: number): boolean => {
if (quota === null || quota === 0) {
return false;
}
if (isQuotaExceeded(quota, usage)) {
return false;
}
return usage >= Math.ceil(quota * QUOTA_WARNING_THRESHOLD);
};
export const computeQuotaFlags = ({ quotas, usage }: ComputeQuotaFlagsOptions): QuotaFlags => {
return {
isDocumentQuotaExceeded: isQuotaExceeded(quotas.documentQuota, usage?.documentCount ?? 0),
isEmailQuotaExceeded: isQuotaExceeded(quotas.emailQuota, usage?.emailCount ?? 0),
isApiQuotaExceeded: isQuotaExceeded(quotas.apiQuota, usage?.apiCount ?? 0),
isDocumentQuotaNearing: isQuotaNearing(quotas.documentQuota, usage?.documentCount ?? 0),
isEmailQuotaNearing: isQuotaNearing(quotas.emailQuota, usage?.emailCount ?? 0),
isApiQuotaNearing: isQuotaNearing(quotas.apiQuota, usage?.apiCount ?? 0),
};
};
@@ -0,0 +1,40 @@
export const QUOTA_WARNING_THRESHOLD = 0.8;
export type QuotaAlertKind = 'quota' | 'quotaNearing';
type GetQuotaAlertKindOptions = {
previousCount: number;
newCount: number;
quota: number;
};
/**
* Determines whether the request that moved the counter from `previousCount` to
* `newCount` crossed an alert threshold.
*
* - 'quota': this request reached (or jumped past) the monthly quota.
* - 'quotaNearing': this request reached the warning threshold (80% of quota).
* - null: no threshold crossed by this request.
*
* Precondition: callers must handle `quota === null` (unlimited) and `quota === 0`
* (blocked) before calling — this function assumes a positive quota.
*/
export const getQuotaAlertKind = (opts: GetQuotaAlertKindOptions): QuotaAlertKind | null => {
const { previousCount, newCount, quota } = opts;
if (newCount >= quota) {
// Only the single request that reached the quota boundary should alert. If the
// same request also skipped past the warning threshold, the quota alert
// supersedes the warning.
return previousCount < quota ? 'quota' : null;
}
// From here newCount < quota, so for tiny quotas (1-4) where the rounded-up
// warning threshold equals the quota itself, the warning can never fire — the
// exhausting request is handled by the quota branch above.
const warningCount = Math.ceil(quota * QUOTA_WARNING_THRESHOLD);
const didCrossWarning = newCount >= warningCount && previousCount < warningCount;
return didCrossWarning ? 'quotaNearing' : null;
};
@@ -12,6 +12,9 @@ export const ZGetOrganisationQuotaFlagsResponseSchema = z.object({
isDocumentQuotaExceeded: z.boolean(),
isEmailQuotaExceeded: z.boolean(),
isApiQuotaExceeded: z.boolean(),
isDocumentQuotaNearing: z.boolean(),
isEmailQuotaNearing: z.boolean(),
isApiQuotaNearing: z.boolean(),
});
export type TGetOrganisationQuotaFlagsResponse = z.infer<typeof ZGetOrganisationQuotaFlagsResponseSchema>;