mirror of
https://github.com/documenso/documenso.git
synced 2026-06-22 04:12:06 +10:00
Merge branch 'main' into fix/cc-recipient-order-last
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
import { useOptionalCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
import { useOptionalCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||||
import { SUPPORT_EMAIL } from '@documenso/lib/constants/app';
|
import { SUPPORT_EMAIL } from '@documenso/lib/constants/app';
|
||||||
|
import { isOrganisationPendingPayment } from '@documenso/lib/utils/billing';
|
||||||
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
|
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
@@ -53,9 +54,9 @@ export const OrganisationBillingBanner = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const subscriptionStatus = organisation?.subscription?.status;
|
const bannerVariant = getBannerVariant(organisation);
|
||||||
|
|
||||||
if (!organisation || subscriptionStatus === undefined || subscriptionStatus === SubscriptionStatus.ACTIVE) {
|
if (!organisation || bannerVariant === null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,27 +64,28 @@ export const OrganisationBillingBanner = () => {
|
|||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
className={cn({
|
className={cn({
|
||||||
'bg-yellow-200 text-yellow-900 dark:bg-yellow-400': subscriptionStatus === SubscriptionStatus.PAST_DUE,
|
'bg-yellow-200 text-yellow-900 dark:bg-yellow-400': bannerVariant === 'PAST_DUE',
|
||||||
'bg-destructive text-destructive-foreground': subscriptionStatus === SubscriptionStatus.INACTIVE,
|
'bg-destructive text-destructive-foreground':
|
||||||
|
bannerVariant === 'INACTIVE' || bannerVariant === 'PENDING_PAYMENT',
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<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="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">
|
<div className="flex items-center">
|
||||||
<AlertTriangle className="mr-2.5 h-5 w-5" />
|
<AlertTriangle className="mr-2.5 h-5 w-5" />
|
||||||
|
|
||||||
{match(subscriptionStatus)
|
{match(bannerVariant)
|
||||||
.with(SubscriptionStatus.PAST_DUE, () => <Trans>Payment overdue</Trans>)
|
.with('PAST_DUE', () => <Trans>Payment overdue</Trans>)
|
||||||
.with(SubscriptionStatus.INACTIVE, () => <Trans>Restricted Access</Trans>)
|
.with('INACTIVE', () => <Trans>Restricted Access</Trans>)
|
||||||
|
.with('PENDING_PAYMENT', () => <Trans>Payment required</Trans>)
|
||||||
.exhaustive()}
|
.exhaustive()}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className={cn({
|
className={cn({
|
||||||
'text-yellow-900 hover:bg-yellow-100 dark:hover:bg-yellow-500':
|
'text-yellow-900 hover:bg-yellow-100 dark:hover:bg-yellow-500': bannerVariant === 'PAST_DUE',
|
||||||
subscriptionStatus === SubscriptionStatus.PAST_DUE,
|
|
||||||
'text-destructive-foreground hover:bg-destructive hover:text-white':
|
'text-destructive-foreground hover:bg-destructive hover:text-white':
|
||||||
subscriptionStatus === SubscriptionStatus.INACTIVE,
|
bannerVariant === 'INACTIVE' || bannerVariant === 'PENDING_PAYMENT',
|
||||||
})}
|
})}
|
||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
onClick={() => setIsOpen(true)}
|
onClick={() => setIsOpen(true)}
|
||||||
@@ -95,8 +97,8 @@ export const OrganisationBillingBanner = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Dialog open={isOpen} onOpenChange={(value) => !isPending && setIsOpen(value)}>
|
<Dialog open={isOpen} onOpenChange={(value) => !isPending && setIsOpen(value)}>
|
||||||
{match(subscriptionStatus)
|
{match(bannerVariant)
|
||||||
.with(SubscriptionStatus.PAST_DUE, () => (
|
.with('PAST_DUE', () => (
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
@@ -117,7 +119,7 @@ export const OrganisationBillingBanner = () => {
|
|||||||
)}
|
)}
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
))
|
))
|
||||||
.with(SubscriptionStatus.INACTIVE, () => (
|
.with('INACTIVE', () => (
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
@@ -153,8 +155,66 @@ export const OrganisationBillingBanner = () => {
|
|||||||
)}
|
)}
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
))
|
))
|
||||||
.otherwise(() => null)}
|
.with('PENDING_PAYMENT', () => (
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
<Trans>Payment required</Trans>
|
||||||
|
</DialogTitle>
|
||||||
|
|
||||||
|
<DialogDescription>
|
||||||
|
<Trans>This organisation is awaiting payment. Complete checkout to unlock it.</Trans>
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<Alert variant="neutral">
|
||||||
|
<AlertDescription>
|
||||||
|
<Trans>
|
||||||
|
If there is any issue with your subscription, please contact us at{' '}
|
||||||
|
<a href={`mailto:${SUPPORT_EMAIL}`}>{SUPPORT_EMAIL}</a>.
|
||||||
|
</Trans>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
{canExecuteOrganisationAction('MANAGE_BILLING', organisation.currentOrganisationRole) && (
|
||||||
|
<DialogFooter>
|
||||||
|
<DialogClose asChild>
|
||||||
|
<Button asChild>
|
||||||
|
<Link to={`/o/${organisation.url}/settings/billing`}>
|
||||||
|
<Trans>Manage Billing</Trans>
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</DialogClose>
|
||||||
|
</DialogFooter>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
))
|
||||||
|
.exhaustive()}
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type BannerVariant = 'PAST_DUE' | 'INACTIVE' | 'PENDING_PAYMENT';
|
||||||
|
|
||||||
|
const getBannerVariant = (organisation: ReturnType<typeof useOptionalCurrentOrganisation>): BannerVariant | null => {
|
||||||
|
if (!organisation) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isOrganisationPendingPayment(organisation)) {
|
||||||
|
return 'PENDING_PAYMENT';
|
||||||
|
}
|
||||||
|
|
||||||
|
const subscriptionStatus = organisation.subscription?.status;
|
||||||
|
|
||||||
|
if (subscriptionStatus === SubscriptionStatus.PAST_DUE) {
|
||||||
|
return 'PAST_DUE';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subscriptionStatus === SubscriptionStatus.INACTIVE) {
|
||||||
|
return 'INACTIVE';
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||||
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
|
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
|
||||||
|
import { isOrganisationPendingPayment } from '@documenso/lib/utils/billing';
|
||||||
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
|
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
|
||||||
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
|
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
|
||||||
import { Badge } from '@documenso/ui/primitives/badge';
|
import { Badge } from '@documenso/ui/primitives/badge';
|
||||||
@@ -21,8 +22,8 @@ export const UserBillingOrganisationsTable = () => {
|
|||||||
return organisations.filter((org) => canExecuteOrganisationAction('MANAGE_BILLING', org.currentOrganisationRole));
|
return organisations.filter((org) => canExecuteOrganisationAction('MANAGE_BILLING', org.currentOrganisationRole));
|
||||||
}, [organisations]);
|
}, [organisations]);
|
||||||
|
|
||||||
const getSubscriptionStatusDisplay = (status: SubscriptionStatus | undefined) => {
|
const getSubscriptionStatusDisplay = (organisation: (typeof billingOrganisations)[number]) => {
|
||||||
return match(status)
|
return match(organisation.subscription?.status)
|
||||||
.with(SubscriptionStatus.ACTIVE, () => ({
|
.with(SubscriptionStatus.ACTIVE, () => ({
|
||||||
label: t({ message: `Active`, context: `Subscription status` }),
|
label: t({ message: `Active`, context: `Subscription status` }),
|
||||||
variant: 'default' as const,
|
variant: 'default' as const,
|
||||||
@@ -35,10 +36,19 @@ export const UserBillingOrganisationsTable = () => {
|
|||||||
label: t({ message: `Inactive`, context: `Subscription status` }),
|
label: t({ message: `Inactive`, context: `Subscription status` }),
|
||||||
variant: 'neutral' as const,
|
variant: 'neutral' as const,
|
||||||
}))
|
}))
|
||||||
.otherwise(() => ({
|
.otherwise(() => {
|
||||||
label: t({ message: `Free`, context: `Subscription status` }),
|
if (isOrganisationPendingPayment(organisation)) {
|
||||||
variant: 'neutral' as const,
|
return {
|
||||||
}));
|
label: t({ message: `Free (Pending)`, context: `Subscription status` }),
|
||||||
|
variant: 'warning' as const,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
label: t({ message: `Free`, context: `Subscription status` }),
|
||||||
|
variant: 'neutral' as const,
|
||||||
|
};
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const columns = useMemo(() => {
|
const columns = useMemo(() => {
|
||||||
@@ -62,9 +72,7 @@ export const UserBillingOrganisationsTable = () => {
|
|||||||
header: t`Subscription Status`,
|
header: t`Subscription Status`,
|
||||||
accessorKey: 'subscription',
|
accessorKey: 'subscription',
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const subscription = row.original.subscription;
|
const { label, variant } = getSubscriptionStatusDisplay(row.original);
|
||||||
const status = subscription?.status;
|
|
||||||
const { label, variant } = getSubscriptionStatusDisplay(status);
|
|
||||||
|
|
||||||
return <Badge variant={variant}>{label}</Badge>;
|
return <Badge variant={variant}>{label}</Badge>;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import { useLingui } from '@lingui/react';
|
|||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import { SubscriptionStatus } from '@prisma/client';
|
import { SubscriptionStatus } from '@prisma/client';
|
||||||
import { Loader } from 'lucide-react';
|
import { Loader } from 'lucide-react';
|
||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { useSearchParams } from 'react-router';
|
||||||
import type Stripe from 'stripe';
|
import type Stripe from 'stripe';
|
||||||
import { match, P } from 'ts-pattern';
|
import { match, P } from 'ts-pattern';
|
||||||
|
|
||||||
@@ -23,12 +25,51 @@ export default function TeamsSettingBillingPage() {
|
|||||||
|
|
||||||
const organisation = useCurrentOrganisation();
|
const organisation = useCurrentOrganisation();
|
||||||
|
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const utils = trpc.useUtils();
|
||||||
|
|
||||||
const { data: subscriptionQuery, isLoading: isLoadingSubscription } =
|
const { data: subscriptionQuery, isLoading: isLoadingSubscription } =
|
||||||
trpc.enterprise.billing.subscription.get.useQuery({
|
trpc.enterprise.billing.subscription.get.useQuery({
|
||||||
organisationId: organisation.id,
|
organisationId: organisation.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (isLoadingSubscription || !subscriptionQuery) {
|
const { mutateAsync: syncSubscription, isPending: isSyncingSubscription } =
|
||||||
|
trpc.enterprise.billing.subscription.sync.useMutation();
|
||||||
|
|
||||||
|
const hasTriggeredCheckoutSyncRef = useRef(false);
|
||||||
|
|
||||||
|
const isCheckoutSuccess = searchParams.get('success') === 'true';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Eagerly sync the subscription from Stripe when returning from a successful
|
||||||
|
* checkout, since the webhook may not have arrived yet.
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isCheckoutSuccess || hasTriggeredCheckoutSyncRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
hasTriggeredCheckoutSyncRef.current = true;
|
||||||
|
|
||||||
|
void syncSubscription({ organisationId: organisation.id })
|
||||||
|
.catch(() => {
|
||||||
|
// Non-fatal, webhooks will converge the subscription state shortly.
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
void utils.enterprise.billing.invalidate();
|
||||||
|
|
||||||
|
setSearchParams(
|
||||||
|
(params) => {
|
||||||
|
params.delete('success');
|
||||||
|
|
||||||
|
return params;
|
||||||
|
},
|
||||||
|
{ replace: true },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}, [isCheckoutSuccess, organisation.id]);
|
||||||
|
|
||||||
|
if (isLoadingSubscription || !subscriptionQuery || isSyncingSubscription) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center rounded-lg py-32">
|
<div className="flex items-center justify-center rounded-lg py-32">
|
||||||
<Loader className="h-6 w-6 animate-spin text-muted-foreground" />
|
<Loader className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { DEFAULT_MINIMUM_ENVELOPE_ITEM_COUNT, PAID_PLAN_LIMITS } from '@documenso/ee/server-only/limits/constants';
|
import { DEFAULT_MINIMUM_ENVELOPE_ITEM_COUNT, PAID_PLAN_LIMITS } from '@documenso/ee/server-only/limits/constants';
|
||||||
import { LimitsProvider } from '@documenso/ee/server-only/limits/provider/client';
|
import { LimitsProvider } from '@documenso/ee/server-only/limits/provider/client';
|
||||||
import { useOptionalCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
import { useOptionalCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||||
|
import { isOrganisationPendingPayment } from '@documenso/lib/utils/billing';
|
||||||
import { TrpcProvider } from '@documenso/trpc/react';
|
import { TrpcProvider } from '@documenso/trpc/react';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
@@ -21,7 +22,11 @@ export default function Layout() {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (organisation?.subscription && organisation.subscription.status === SubscriptionStatus.INACTIVE) {
|
const isRestricted =
|
||||||
|
(organisation.subscription && organisation.subscription.status === SubscriptionStatus.INACTIVE) ||
|
||||||
|
isOrganisationPendingPayment(organisation);
|
||||||
|
|
||||||
|
if (isRestricted) {
|
||||||
return {
|
return {
|
||||||
quota: {
|
quota: {
|
||||||
documents: 0,
|
documents: 0,
|
||||||
@@ -42,7 +47,7 @@ export default function Layout() {
|
|||||||
remaining: PAID_PLAN_LIMITS,
|
remaining: PAID_PLAN_LIMITS,
|
||||||
maximumEnvelopeItemCount: DEFAULT_MINIMUM_ENVELOPE_ITEM_COUNT,
|
maximumEnvelopeItemCount: DEFAULT_MINIMUM_ENVELOPE_ITEM_COUNT,
|
||||||
};
|
};
|
||||||
}, [organisation?.subscription]);
|
}, [organisation]);
|
||||||
|
|
||||||
if (!team) {
|
if (!team) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||||
import { INTERNAL_CLAIM_ID } from '@documenso/lib/types/subscription';
|
import { INTERNAL_CLAIM_ID } from '@documenso/lib/types/subscription';
|
||||||
|
import { isOrganisationPendingPayment } from '@documenso/lib/utils/billing';
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
import { DocumentSource, EnvelopeType, SubscriptionStatus } from '@prisma/client';
|
import { DocumentSource, EnvelopeType, SubscriptionStatus } from '@prisma/client';
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
@@ -69,6 +70,15 @@ export const getServerLimits = async ({ userId, teamId }: GetServerLimitsOptions
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Early return for organisations created ahead of a paid checkout that are still awaiting payment.
|
||||||
|
if (isOrganisationPendingPayment(organisation)) {
|
||||||
|
return {
|
||||||
|
quota: INACTIVE_PLAN_LIMITS,
|
||||||
|
remaining: INACTIVE_PLAN_LIMITS,
|
||||||
|
maximumEnvelopeItemCount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Allow unlimited documents for users with an unlimited documents claim.
|
// Allow unlimited documents for users with an unlimited documents claim.
|
||||||
// This also allows "free" claim users without subscriptions if they have this flag.
|
// This also allows "free" claim users without subscriptions if they have this flag.
|
||||||
if (organisation.organisationClaim.flags.unlimitedDocuments) {
|
if (organisation.organisationClaim.flags.unlimitedDocuments) {
|
||||||
|
|||||||
@@ -1,20 +1,13 @@
|
|||||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
import { stripe } from '@documenso/lib/server-only/stripe';
|
import { stripe } from '@documenso/lib/server-only/stripe';
|
||||||
import type Stripe from 'stripe';
|
|
||||||
|
|
||||||
export type CreateCheckoutSessionOptions = {
|
export type CreateCheckoutSessionOptions = {
|
||||||
customerId: string;
|
customerId: string;
|
||||||
priceId: string;
|
priceId: string;
|
||||||
returnUrl: string;
|
returnUrl: string;
|
||||||
subscriptionMetadata?: Stripe.Metadata;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createCheckoutSession = async ({
|
export const createCheckoutSession = async ({ customerId, priceId, returnUrl }: CreateCheckoutSessionOptions) => {
|
||||||
customerId,
|
|
||||||
priceId,
|
|
||||||
returnUrl,
|
|
||||||
subscriptionMetadata,
|
|
||||||
}: CreateCheckoutSessionOptions) => {
|
|
||||||
const session = await stripe.checkout.sessions.create({
|
const session = await stripe.checkout.sessions.create({
|
||||||
customer: customerId,
|
customer: customerId,
|
||||||
mode: 'subscription',
|
mode: 'subscription',
|
||||||
@@ -27,9 +20,6 @@ export const createCheckoutSession = async ({
|
|||||||
success_url: `${returnUrl}?success=true`,
|
success_url: `${returnUrl}?success=true`,
|
||||||
cancel_url: `${returnUrl}?canceled=true`,
|
cancel_url: `${returnUrl}?canceled=true`,
|
||||||
billing_address_collection: 'required',
|
billing_address_collection: 'required',
|
||||||
subscription_data: {
|
|
||||||
metadata: subscriptionMetadata,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!session.url) {
|
if (!session.url) {
|
||||||
|
|||||||
@@ -0,0 +1,283 @@
|
|||||||
|
import { createOrganisationClaimUpsertData } from '@documenso/lib/server-only/organisation/create-organisation';
|
||||||
|
import { type Stripe, stripe } from '@documenso/lib/server-only/stripe';
|
||||||
|
import { getSubscriptionClaim } from '@documenso/lib/server-only/subscription/get-subscription-claim';
|
||||||
|
import { INTERNAL_CLAIM_ID } from '@documenso/lib/types/subscription';
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
import { OrganisationType, type Prisma, SubscriptionStatus } from '@prisma/client';
|
||||||
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
|
const LIVE_SUBSCRIPTION_STATUSES: Stripe.Subscription.Status[] = ['active', 'trialing', 'past_due'];
|
||||||
|
|
||||||
|
export type SyncStripeCustomerSubscriptionOptions = {
|
||||||
|
customerId: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When true, the organisationClaim will not be synced.
|
||||||
|
*
|
||||||
|
* Used by the admin sync route to update only the Subscription
|
||||||
|
* row while leaving claim entitlements untouched.
|
||||||
|
*/
|
||||||
|
bypassClaimUpdate?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Idempotent, convergent sync of a Stripe customer's subscription state into the local database.
|
||||||
|
*
|
||||||
|
* Fetches the current truth from Stripe and writes it locally, regardless of which
|
||||||
|
* webhook event (or manual trigger) initiated the sync. Safe to run at any time,
|
||||||
|
* any number of times.
|
||||||
|
*
|
||||||
|
* This function never creates organisations.
|
||||||
|
*/
|
||||||
|
export const syncStripeCustomerSubscription = async ({
|
||||||
|
customerId,
|
||||||
|
bypassClaimUpdate = false,
|
||||||
|
}: SyncStripeCustomerSubscriptionOptions) => {
|
||||||
|
// Note: `data.items.data.price.product` would exceed Stripe's 4-level expansion
|
||||||
|
// limit on list endpoints, so the product is fetched separately when needed.
|
||||||
|
const stripeSubscriptions = await stripe.subscriptions.list({
|
||||||
|
customer: customerId,
|
||||||
|
status: 'all',
|
||||||
|
limit: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
const liveSubscriptions = stripeSubscriptions.data.filter((subscription) =>
|
||||||
|
LIVE_SUBSCRIPTION_STATUSES.includes(subscription.status),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (liveSubscriptions.length > 1) {
|
||||||
|
console.error(`Customer ${customerId} has ${liveSubscriptions.length} live subscriptions, expected at most 1`);
|
||||||
|
|
||||||
|
throw new Error(`Customer ${customerId} has multiple live subscriptions`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const organisation = await prisma.organisation.findFirst({
|
||||||
|
where: {
|
||||||
|
customerId,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
organisationClaim: true,
|
||||||
|
subscription: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!organisation) {
|
||||||
|
console.error(`Organisation not found for customer ${customerId}, nothing to sync`);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const liveSubscription = liveSubscriptions[0];
|
||||||
|
|
||||||
|
if (!liveSubscription) {
|
||||||
|
await handleNoLiveSubscription({ organisation });
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await handleLiveSubscription({
|
||||||
|
organisation,
|
||||||
|
subscription: liveSubscription,
|
||||||
|
customerId,
|
||||||
|
bypassClaimUpdate,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
type OrganisationWithClaimAndSubscription = Prisma.OrganisationGetPayload<{
|
||||||
|
include: { organisationClaim: true; subscription: true };
|
||||||
|
}>;
|
||||||
|
|
||||||
|
type HandleNoLiveSubscriptionOptions = {
|
||||||
|
organisation: OrganisationWithClaimAndSubscription;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNoLiveSubscription = async ({ organisation }: HandleNoLiveSubscriptionOptions) => {
|
||||||
|
// Individuals get their subscription deleted so they can return to the free plan.
|
||||||
|
if (organisation.organisationClaim.originalSubscriptionClaimId === INTERNAL_CLAIM_ID.INDIVIDUAL) {
|
||||||
|
const freeSubscriptionClaim = await getSubscriptionClaim(INTERNAL_CLAIM_ID.FREE);
|
||||||
|
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
if (organisation.subscription) {
|
||||||
|
await tx.subscription.delete({
|
||||||
|
where: {
|
||||||
|
id: organisation.subscription.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await tx.organisationClaim.update({
|
||||||
|
where: {
|
||||||
|
id: organisation.organisationClaim.id,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
originalSubscriptionClaimId: INTERNAL_CLAIM_ID.FREE,
|
||||||
|
...createOrganisationClaimUpsertData(freeSubscriptionClaim),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For all other cases, mark the subscription as inactive if a row exists.
|
||||||
|
if (organisation.subscription) {
|
||||||
|
await prisma.subscription.update({
|
||||||
|
where: {
|
||||||
|
id: organisation.subscription.id,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
status: SubscriptionStatus.INACTIVE,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
type HandleLiveSubscriptionOptions = {
|
||||||
|
organisation: OrganisationWithClaimAndSubscription;
|
||||||
|
subscription: Stripe.Subscription;
|
||||||
|
customerId: string;
|
||||||
|
bypassClaimUpdate: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLiveSubscription = async ({
|
||||||
|
organisation,
|
||||||
|
subscription,
|
||||||
|
customerId,
|
||||||
|
bypassClaimUpdate,
|
||||||
|
}: HandleLiveSubscriptionOptions) => {
|
||||||
|
if (subscription.items.data.length !== 1) {
|
||||||
|
console.error(`No support for multiple subscription items on subscription ${subscription.id}`);
|
||||||
|
|
||||||
|
throw new Error(`No support for multiple subscription items on subscription ${subscription.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const subscriptionItem = subscription.items.data[0];
|
||||||
|
|
||||||
|
const claim = await extractStripeClaim(subscriptionItem.price);
|
||||||
|
|
||||||
|
if (!claim) {
|
||||||
|
console.error(`Subscription claim on ${subscriptionItem.price.id} not found`);
|
||||||
|
|
||||||
|
throw new Error(`Subscription claim on ${subscriptionItem.price.id} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = match(subscription.status)
|
||||||
|
.with('active', () => SubscriptionStatus.ACTIVE)
|
||||||
|
.with('trialing', () => SubscriptionStatus.ACTIVE)
|
||||||
|
.with('past_due', () => SubscriptionStatus.PAST_DUE)
|
||||||
|
.otherwise(() => SubscriptionStatus.INACTIVE);
|
||||||
|
|
||||||
|
const periodEnd =
|
||||||
|
subscription.status === 'trialing' && subscription.trial_end
|
||||||
|
? new Date(subscription.trial_end * 1000)
|
||||||
|
: new Date(subscription.current_period_end * 1000);
|
||||||
|
|
||||||
|
const shouldUpdateClaim =
|
||||||
|
!bypassClaimUpdate && organisation.organisationClaim.originalSubscriptionClaimId !== claim.id;
|
||||||
|
|
||||||
|
// Migrate the organisation type if it is no longer an individual/free plan.
|
||||||
|
// Never demote an ORGANISATION back to PERSONAL.
|
||||||
|
const shouldMigrateOrganisationType =
|
||||||
|
organisation.type === OrganisationType.PERSONAL &&
|
||||||
|
claim.id !== INTERNAL_CLAIM_ID.INDIVIDUAL &&
|
||||||
|
claim.id !== INTERNAL_CLAIM_ID.FREE;
|
||||||
|
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
await tx.subscription.upsert({
|
||||||
|
where: {
|
||||||
|
organisationId: organisation.id,
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
organisationId: organisation.id,
|
||||||
|
status,
|
||||||
|
customerId,
|
||||||
|
planId: subscription.id,
|
||||||
|
priceId: subscriptionItem.price.id,
|
||||||
|
periodEnd,
|
||||||
|
cancelAtPeriodEnd: subscription.cancel_at_period_end,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
status,
|
||||||
|
customerId,
|
||||||
|
planId: subscription.id,
|
||||||
|
priceId: subscriptionItem.price.id,
|
||||||
|
periodEnd,
|
||||||
|
cancelAtPeriodEnd: subscription.cancel_at_period_end,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (shouldUpdateClaim) {
|
||||||
|
await tx.organisationClaim.update({
|
||||||
|
where: {
|
||||||
|
id: organisation.organisationClaim.id,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
originalSubscriptionClaimId: claim.id,
|
||||||
|
...createOrganisationClaimUpsertData(claim),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldMigrateOrganisationType) {
|
||||||
|
await tx.organisation.update({
|
||||||
|
where: {
|
||||||
|
id: organisation.id,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
type: OrganisationType.ORGANISATION,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks the price metadata for a claimId, if it is missing it will fetch
|
||||||
|
* and check the product metadata for a claimId.
|
||||||
|
*
|
||||||
|
* The order of priority is:
|
||||||
|
* 1. Price metadata
|
||||||
|
* 2. Product metadata
|
||||||
|
*
|
||||||
|
* @returns The claimId or null if no claimId is found.
|
||||||
|
*/
|
||||||
|
export const extractStripeClaimId = async (priceId: Stripe.Price) => {
|
||||||
|
if (priceId.metadata.claimId) {
|
||||||
|
return priceId.metadata.claimId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use the expanded product when available to avoid an extra API call.
|
||||||
|
if (typeof priceId.product !== 'string' && 'metadata' in priceId.product) {
|
||||||
|
return priceId.product.metadata.claimId || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const productId = typeof priceId.product === 'string' ? priceId.product : priceId.product.id;
|
||||||
|
|
||||||
|
const product = await stripe.products.retrieve(productId);
|
||||||
|
|
||||||
|
return product.metadata.claimId || null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks the price metadata for a claimId, if it is missing it will fetch
|
||||||
|
* and check the product metadata for a claimId.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
export const extractStripeClaim = async (priceId: Stripe.Price) => {
|
||||||
|
const claimId = await extractStripeClaimId(priceId);
|
||||||
|
|
||||||
|
if (!claimId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const subscriptionClaim = await prisma.subscriptionClaim.findFirst({
|
||||||
|
where: { id: claimId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!subscriptionClaim) {
|
||||||
|
console.error(`Subscription claim ${claimId} not found`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return subscriptionClaim;
|
||||||
|
};
|
||||||
@@ -2,17 +2,29 @@ import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
|||||||
import type { Stripe } from '@documenso/lib/server-only/stripe';
|
import type { Stripe } from '@documenso/lib/server-only/stripe';
|
||||||
import { stripe } from '@documenso/lib/server-only/stripe';
|
import { stripe } from '@documenso/lib/server-only/stripe';
|
||||||
import { env } from '@documenso/lib/utils/env';
|
import { env } from '@documenso/lib/utils/env';
|
||||||
import { match } from 'ts-pattern';
|
|
||||||
|
|
||||||
import { onSubscriptionCreated } from './on-subscription-created';
|
import { syncStripeCustomerSubscription } from '../sync-stripe-customer-subscription';
|
||||||
import { onSubscriptionDeleted } from './on-subscription-deleted';
|
|
||||||
import { onSubscriptionUpdated } from './on-subscription-updated';
|
|
||||||
|
|
||||||
type StripeWebhookResponse = {
|
type StripeWebhookResponse = {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
message: string;
|
message: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Events that trigger a sync of the customer's subscription state.
|
||||||
|
*
|
||||||
|
* The event payload is never trusted beyond extracting the customer ID,
|
||||||
|
* the sync function fetches the current truth from Stripe.
|
||||||
|
*/
|
||||||
|
const SYNCED_EVENT_TYPES: string[] = [
|
||||||
|
'customer.subscription.created',
|
||||||
|
'customer.subscription.updated',
|
||||||
|
'customer.subscription.deleted',
|
||||||
|
'checkout.session.completed',
|
||||||
|
'invoice.payment_succeeded',
|
||||||
|
'invoice.payment_failed',
|
||||||
|
];
|
||||||
|
|
||||||
export const stripeWebhookHandler = async (req: Request): Promise<Response> => {
|
export const stripeWebhookHandler = async (req: Request): Promise<Response> => {
|
||||||
try {
|
try {
|
||||||
const isBillingEnabled = IS_BILLING_ENABLED();
|
const isBillingEnabled = IS_BILLING_ENABLED();
|
||||||
@@ -60,69 +72,45 @@ export const stripeWebhookHandler = async (req: Request): Promise<Response> => {
|
|||||||
|
|
||||||
const event = stripe.webhooks.constructEvent(payload, signature, webhookSecret);
|
const event = stripe.webhooks.constructEvent(payload, signature, webhookSecret);
|
||||||
|
|
||||||
/**
|
if (!SYNCED_EVENT_TYPES.includes(event.type)) {
|
||||||
* Notes:
|
return Response.json(
|
||||||
* - Dropped invoice.payment_succeeded
|
{
|
||||||
* - Dropped invoice.payment_failed
|
success: true,
|
||||||
* - Dropped checkout-session.completed
|
message: 'Webhook received',
|
||||||
*/
|
} satisfies StripeWebhookResponse,
|
||||||
return await match(event.type)
|
{ status: 200 },
|
||||||
.with('customer.subscription.created', async () => {
|
);
|
||||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
}
|
||||||
const subscription = event.data.object as Stripe.Subscription;
|
|
||||||
|
|
||||||
await onSubscriptionCreated({ subscription });
|
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||||
|
const eventObject = event.data.object as { customer?: string | Stripe.Customer | null };
|
||||||
|
|
||||||
return Response.json({ success: true, message: 'Webhook received' } satisfies StripeWebhookResponse, {
|
const customerId = typeof eventObject.customer === 'string' ? eventObject.customer : eventObject.customer?.id;
|
||||||
status: 200,
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.with('customer.subscription.updated', async () => {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
|
||||||
const subscription = event.data.object as Stripe.Subscription;
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
if (!customerId) {
|
||||||
const previousAttributes = event.data.previous_attributes as Partial<Stripe.Subscription> | null;
|
console.error(`No customer found on ${event.type} event ${event.id}, nothing to sync`);
|
||||||
|
|
||||||
await onSubscriptionUpdated({ subscription, previousAttributes });
|
return Response.json(
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
message: 'Webhook received',
|
||||||
|
} satisfies StripeWebhookResponse,
|
||||||
|
{ status: 200 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return Response.json({ success: true, message: 'Webhook received' } satisfies StripeWebhookResponse, {
|
await syncStripeCustomerSubscription({ customerId });
|
||||||
status: 200,
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.with('customer.subscription.deleted', async () => {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
|
||||||
const subscription = event.data.object as Stripe.Subscription;
|
|
||||||
|
|
||||||
await onSubscriptionDeleted({ subscription });
|
return Response.json(
|
||||||
|
{
|
||||||
return Response.json(
|
success: true,
|
||||||
{
|
message: 'Webhook received',
|
||||||
success: true,
|
} satisfies StripeWebhookResponse,
|
||||||
message: 'Webhook received',
|
{ status: 200 },
|
||||||
} satisfies StripeWebhookResponse,
|
);
|
||||||
{ status: 200 },
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.otherwise(() => {
|
|
||||||
return Response.json(
|
|
||||||
{
|
|
||||||
success: true,
|
|
||||||
message: 'Webhook received',
|
|
||||||
} satisfies StripeWebhookResponse,
|
|
||||||
{ status: 200 },
|
|
||||||
);
|
|
||||||
});
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
|
|
||||||
if (err instanceof Response) {
|
|
||||||
const message = await err.json();
|
|
||||||
console.error(message);
|
|
||||||
|
|
||||||
return err;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Response.json(
|
return Response.json(
|
||||||
{
|
{
|
||||||
success: false,
|
success: false,
|
||||||
|
|||||||
@@ -1,214 +0,0 @@
|
|||||||
import {
|
|
||||||
createOrganisation,
|
|
||||||
createOrganisationClaimUpsertData,
|
|
||||||
} from '@documenso/lib/server-only/organisation/create-organisation';
|
|
||||||
import type { Stripe } from '@documenso/lib/server-only/stripe';
|
|
||||||
import type { StripeOrganisationCreateMetadata } from '@documenso/lib/types/subscription';
|
|
||||||
import { INTERNAL_CLAIM_ID, ZStripeOrganisationCreateMetadataSchema } from '@documenso/lib/types/subscription';
|
|
||||||
import { prisma } from '@documenso/prisma';
|
|
||||||
import { OrganisationType, type SubscriptionClaim, SubscriptionStatus } from '@prisma/client';
|
|
||||||
import { match } from 'ts-pattern';
|
|
||||||
|
|
||||||
import { extractStripeClaim } from './on-subscription-updated';
|
|
||||||
|
|
||||||
export type OnSubscriptionCreatedOptions = {
|
|
||||||
subscription: Stripe.Subscription;
|
|
||||||
};
|
|
||||||
|
|
||||||
type StripeWebhookResponse = {
|
|
||||||
success: boolean;
|
|
||||||
message: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Todo: We might want to pull this into a job so we can do steps. Since if organisation creation passes but
|
|
||||||
* fails after this would be automatically rerun by Stripe, which means duplicate organisations can be
|
|
||||||
* potentially created.
|
|
||||||
*/
|
|
||||||
export const onSubscriptionCreated = async ({ subscription }: OnSubscriptionCreatedOptions) => {
|
|
||||||
const customerId = typeof subscription.customer === 'string' ? subscription.customer : subscription.customer.id;
|
|
||||||
|
|
||||||
// Todo: logging
|
|
||||||
if (subscription.items.data.length !== 1) {
|
|
||||||
console.error('No support for multiple items');
|
|
||||||
|
|
||||||
throw Response.json(
|
|
||||||
{
|
|
||||||
success: false,
|
|
||||||
message: 'No support for multiple items',
|
|
||||||
} satisfies StripeWebhookResponse,
|
|
||||||
{ status: 500 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const subscriptionItem = subscription.items.data[0];
|
|
||||||
const claim = await extractStripeClaim(subscriptionItem.price);
|
|
||||||
|
|
||||||
// Todo: logging
|
|
||||||
if (!claim) {
|
|
||||||
console.error(`Subscription claim on ${subscriptionItem.price.id} not found`);
|
|
||||||
|
|
||||||
throw Response.json(
|
|
||||||
{
|
|
||||||
success: false,
|
|
||||||
message: `Subscription claim on ${subscriptionItem.price.id} not found`,
|
|
||||||
} satisfies StripeWebhookResponse,
|
|
||||||
{ status: 500 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const organisationCreateData = subscription.metadata?.organisationCreateData;
|
|
||||||
|
|
||||||
// A new subscription can be for an existing organisation or a new one.
|
|
||||||
const organisationId = organisationCreateData
|
|
||||||
? await handleOrganisationCreate({
|
|
||||||
customerId,
|
|
||||||
claim,
|
|
||||||
unknownCreateData: organisationCreateData,
|
|
||||||
})
|
|
||||||
: await handleOrganisationUpdate({
|
|
||||||
customerId,
|
|
||||||
claim,
|
|
||||||
});
|
|
||||||
|
|
||||||
const status = match(subscription.status)
|
|
||||||
.with('active', () => SubscriptionStatus.ACTIVE)
|
|
||||||
.with('trialing', () => SubscriptionStatus.ACTIVE)
|
|
||||||
.with('past_due', () => SubscriptionStatus.PAST_DUE)
|
|
||||||
.otherwise(() => SubscriptionStatus.INACTIVE);
|
|
||||||
|
|
||||||
const periodEnd =
|
|
||||||
subscription.status === 'trialing' && subscription.trial_end
|
|
||||||
? new Date(subscription.trial_end * 1000)
|
|
||||||
: new Date(subscription.current_period_end * 1000);
|
|
||||||
|
|
||||||
await prisma.subscription.upsert({
|
|
||||||
where: {
|
|
||||||
organisationId,
|
|
||||||
},
|
|
||||||
create: {
|
|
||||||
organisationId,
|
|
||||||
status,
|
|
||||||
customerId,
|
|
||||||
planId: subscription.id,
|
|
||||||
priceId: subscription.items.data[0].price.id,
|
|
||||||
periodEnd,
|
|
||||||
cancelAtPeriodEnd: subscription.cancel_at_period_end,
|
|
||||||
},
|
|
||||||
update: {
|
|
||||||
status,
|
|
||||||
customerId,
|
|
||||||
planId: subscription.id,
|
|
||||||
priceId: subscription.items.data[0].price.id,
|
|
||||||
periodEnd,
|
|
||||||
cancelAtPeriodEnd: subscription.cancel_at_period_end,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
type HandleOrganisationCreateOptions = {
|
|
||||||
customerId: string;
|
|
||||||
claim: Omit<SubscriptionClaim, 'createdAt' | 'updatedAt'>;
|
|
||||||
unknownCreateData: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handles the creation of an organisation.
|
|
||||||
*/
|
|
||||||
const handleOrganisationCreate = async ({ customerId, claim, unknownCreateData }: HandleOrganisationCreateOptions) => {
|
|
||||||
let organisationCreateFlowData: StripeOrganisationCreateMetadata | null = null;
|
|
||||||
|
|
||||||
const parseResult = ZStripeOrganisationCreateMetadataSchema.safeParse(JSON.parse(unknownCreateData));
|
|
||||||
|
|
||||||
if (!parseResult.success) {
|
|
||||||
console.error('Invalid organisation create flow data');
|
|
||||||
|
|
||||||
throw Response.json(
|
|
||||||
{
|
|
||||||
success: false,
|
|
||||||
message: 'Invalid organisation create flow data',
|
|
||||||
} satisfies StripeWebhookResponse,
|
|
||||||
{ status: 500 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
organisationCreateFlowData = parseResult.data;
|
|
||||||
|
|
||||||
const createdOrganisation = await createOrganisation({
|
|
||||||
name: organisationCreateFlowData.organisationName,
|
|
||||||
userId: organisationCreateFlowData.userId,
|
|
||||||
type: OrganisationType.ORGANISATION,
|
|
||||||
customerId,
|
|
||||||
claim,
|
|
||||||
});
|
|
||||||
|
|
||||||
return createdOrganisation.id;
|
|
||||||
};
|
|
||||||
|
|
||||||
type HandleOrganisationUpdateOptions = {
|
|
||||||
customerId: string;
|
|
||||||
claim: Omit<SubscriptionClaim, 'createdAt' | 'updatedAt'>;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handles the updating an exist organisation claims.
|
|
||||||
*/
|
|
||||||
const handleOrganisationUpdate = async ({ customerId, claim }: HandleOrganisationUpdateOptions) => {
|
|
||||||
const organisation = await prisma.organisation.findFirst({
|
|
||||||
where: {
|
|
||||||
customerId,
|
|
||||||
},
|
|
||||||
include: {
|
|
||||||
subscription: true,
|
|
||||||
organisationClaim: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!organisation) {
|
|
||||||
throw Response.json(
|
|
||||||
{
|
|
||||||
success: false,
|
|
||||||
message: `Organisation not found`,
|
|
||||||
} satisfies StripeWebhookResponse,
|
|
||||||
{ status: 500 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Todo: logging
|
|
||||||
if (organisation.subscription && organisation.subscription.status !== SubscriptionStatus.INACTIVE) {
|
|
||||||
console.error('Organisation already has an active subscription');
|
|
||||||
|
|
||||||
// This should never happen
|
|
||||||
throw Response.json(
|
|
||||||
{
|
|
||||||
success: false,
|
|
||||||
message: `Organisation already has an active subscription`,
|
|
||||||
} satisfies StripeWebhookResponse,
|
|
||||||
{ status: 500 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let newOrganisationType: OrganisationType = OrganisationType.ORGANISATION;
|
|
||||||
|
|
||||||
// Keep the organisation as personal if the claim is for an individual.
|
|
||||||
if (organisation.type === OrganisationType.PERSONAL && claim.id === INTERNAL_CLAIM_ID.INDIVIDUAL) {
|
|
||||||
newOrganisationType = OrganisationType.PERSONAL;
|
|
||||||
}
|
|
||||||
|
|
||||||
await prisma.organisation.update({
|
|
||||||
where: {
|
|
||||||
id: organisation.id,
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
type: newOrganisationType,
|
|
||||||
organisationClaim: {
|
|
||||||
update: {
|
|
||||||
originalSubscriptionClaimId: claim.id,
|
|
||||||
...createOrganisationClaimUpsertData(claim),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return organisation.id;
|
|
||||||
};
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
import { createOrganisationClaimUpsertData } from '@documenso/lib/server-only/organisation/create-organisation';
|
|
||||||
import type { Stripe } from '@documenso/lib/server-only/stripe';
|
|
||||||
import { getSubscriptionClaim } from '@documenso/lib/server-only/subscription/get-subscription-claim';
|
|
||||||
import { INTERNAL_CLAIM_ID } from '@documenso/lib/types/subscription';
|
|
||||||
import { prisma } from '@documenso/prisma';
|
|
||||||
import { SubscriptionStatus } from '@prisma/client';
|
|
||||||
import { extractStripeClaimId } from './on-subscription-updated';
|
|
||||||
|
|
||||||
export type OnSubscriptionDeletedOptions = {
|
|
||||||
subscription: Stripe.Subscription;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const onSubscriptionDeleted = async ({ subscription }: OnSubscriptionDeletedOptions) => {
|
|
||||||
const existingSubscription = await prisma.subscription.findUnique({
|
|
||||||
where: {
|
|
||||||
planId: subscription.id,
|
|
||||||
},
|
|
||||||
include: {
|
|
||||||
organisation: {
|
|
||||||
include: {
|
|
||||||
organisationClaim: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// If the subscription doesn't exist, we don't need to do anything.
|
|
||||||
if (!existingSubscription) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const subscriptionClaimId = await extractClaimIdFromStripeSubscription(subscription);
|
|
||||||
|
|
||||||
// Individuals get their subscription deleted so they can return to the
|
|
||||||
// free plan.
|
|
||||||
if (subscriptionClaimId === INTERNAL_CLAIM_ID.INDIVIDUAL) {
|
|
||||||
const freeSubscriptionClaim = await getSubscriptionClaim(INTERNAL_CLAIM_ID.FREE);
|
|
||||||
|
|
||||||
await prisma.$transaction(async (tx) => {
|
|
||||||
await tx.subscription.delete({
|
|
||||||
where: {
|
|
||||||
id: existingSubscription.id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await tx.organisationClaim.update({
|
|
||||||
where: {
|
|
||||||
id: existingSubscription.organisation.organisationClaim.id,
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
originalSubscriptionClaimId: INTERNAL_CLAIM_ID.FREE,
|
|
||||||
...createOrganisationClaimUpsertData(freeSubscriptionClaim),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// For all other cases, mark the subscription as inactive since
|
|
||||||
// they should still have a "Personal" account.
|
|
||||||
await prisma.subscription.update({
|
|
||||||
where: {
|
|
||||||
id: existingSubscription.id,
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
status: SubscriptionStatus.INACTIVE,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extracts the claim ID from the Stripe subscription.
|
|
||||||
*
|
|
||||||
* Returns `null` if no claim ID found.
|
|
||||||
*/
|
|
||||||
const extractClaimIdFromStripeSubscription = async (subscription: Stripe.Subscription) => {
|
|
||||||
const deletedItem = subscription.items.data[0];
|
|
||||||
|
|
||||||
if (!deletedItem) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
return await extractStripeClaimId(deletedItem.price);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,194 +0,0 @@
|
|||||||
import { createOrganisationClaimUpsertData } from '@documenso/lib/server-only/organisation/create-organisation';
|
|
||||||
import { type Stripe, stripe } from '@documenso/lib/server-only/stripe';
|
|
||||||
import { INTERNAL_CLAIM_ID } from '@documenso/lib/types/subscription';
|
|
||||||
import { prisma } from '@documenso/prisma';
|
|
||||||
import { OrganisationType, SubscriptionStatus } from '@prisma/client';
|
|
||||||
import { match } from 'ts-pattern';
|
|
||||||
|
|
||||||
export type OnSubscriptionUpdatedOptions = {
|
|
||||||
subscription: Stripe.Subscription;
|
|
||||||
previousAttributes: Partial<Stripe.Subscription> | null;
|
|
||||||
/**
|
|
||||||
* When true, the organisationClaim will not be synced.
|
|
||||||
*
|
|
||||||
* Used by the admin sync route to update only the Subscription
|
|
||||||
* row while leaving claim entitlements untouched.
|
|
||||||
*/
|
|
||||||
bypassClaimUpdate?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
type StripeWebhookResponse = {
|
|
||||||
success: boolean;
|
|
||||||
message: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const onSubscriptionUpdated = async ({
|
|
||||||
subscription,
|
|
||||||
previousAttributes,
|
|
||||||
bypassClaimUpdate = false,
|
|
||||||
}: OnSubscriptionUpdatedOptions) => {
|
|
||||||
const customerId = typeof subscription.customer === 'string' ? subscription.customer : subscription.customer.id;
|
|
||||||
|
|
||||||
// Todo: logging
|
|
||||||
if (subscription.items.data.length !== 1) {
|
|
||||||
console.error('No support for multiple items');
|
|
||||||
|
|
||||||
throw Response.json(
|
|
||||||
{
|
|
||||||
success: false,
|
|
||||||
message: 'No support for multiple items',
|
|
||||||
} satisfies StripeWebhookResponse,
|
|
||||||
{ status: 500 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const organisation = await prisma.organisation.findFirst({
|
|
||||||
where: {
|
|
||||||
customerId,
|
|
||||||
},
|
|
||||||
include: {
|
|
||||||
organisationClaim: true,
|
|
||||||
subscription: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!organisation) {
|
|
||||||
throw Response.json(
|
|
||||||
{
|
|
||||||
success: false,
|
|
||||||
message: `Organisation not found`,
|
|
||||||
} satisfies StripeWebhookResponse,
|
|
||||||
{ status: 500 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
organisation.subscription &&
|
|
||||||
organisation.subscription.status !== SubscriptionStatus.INACTIVE &&
|
|
||||||
organisation.subscription.planId !== subscription.id
|
|
||||||
) {
|
|
||||||
console.error('[WARNING]: Organisation might have two subscriptions');
|
|
||||||
}
|
|
||||||
|
|
||||||
const previousItem = previousAttributes?.items?.data[0];
|
|
||||||
const updatedItem = subscription.items.data[0];
|
|
||||||
|
|
||||||
const previousSubscriptionClaimId = previousItem ? await extractStripeClaimId(previousItem.price) : null;
|
|
||||||
const updatedSubscriptionClaim = await extractStripeClaim(updatedItem.price);
|
|
||||||
|
|
||||||
if (!updatedSubscriptionClaim) {
|
|
||||||
console.error(`Subscription claim on ${updatedItem.price.id} not found`);
|
|
||||||
|
|
||||||
throw Response.json(
|
|
||||||
{
|
|
||||||
success: false,
|
|
||||||
message: `Subscription claim on ${updatedItem.price.id} not found`,
|
|
||||||
} satisfies StripeWebhookResponse,
|
|
||||||
{ status: 500 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const newClaimFound = previousSubscriptionClaimId !== updatedSubscriptionClaim.id;
|
|
||||||
|
|
||||||
const status = match(subscription.status)
|
|
||||||
.with('active', () => SubscriptionStatus.ACTIVE)
|
|
||||||
.with('trialing', () => SubscriptionStatus.ACTIVE)
|
|
||||||
.with('past_due', () => SubscriptionStatus.PAST_DUE)
|
|
||||||
.otherwise(() => SubscriptionStatus.INACTIVE);
|
|
||||||
|
|
||||||
const periodEnd =
|
|
||||||
subscription.status === 'trialing' && subscription.trial_end
|
|
||||||
? new Date(subscription.trial_end * 1000)
|
|
||||||
: new Date(subscription.current_period_end * 1000);
|
|
||||||
|
|
||||||
// Migrate the organisation type if it is no longer an individual plan.
|
|
||||||
if (
|
|
||||||
updatedSubscriptionClaim.id !== INTERNAL_CLAIM_ID.INDIVIDUAL &&
|
|
||||||
updatedSubscriptionClaim.id !== INTERNAL_CLAIM_ID.FREE &&
|
|
||||||
organisation.type === OrganisationType.PERSONAL
|
|
||||||
) {
|
|
||||||
await prisma.organisation.update({
|
|
||||||
where: {
|
|
||||||
id: organisation.id,
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
type: OrganisationType.ORGANISATION,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
await prisma.$transaction(async (tx) => {
|
|
||||||
await tx.subscription.update({
|
|
||||||
where: {
|
|
||||||
organisationId: organisation.id,
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
status: status,
|
|
||||||
planId: subscription.id,
|
|
||||||
priceId: subscription.items.data[0].price.id,
|
|
||||||
periodEnd,
|
|
||||||
cancelAtPeriodEnd: subscription.cancel_at_period_end,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Override current organisation claim if new one is found.
|
|
||||||
// Skipped when bypassClaimUpdate is set.
|
|
||||||
if (!bypassClaimUpdate && newClaimFound) {
|
|
||||||
await tx.organisationClaim.update({
|
|
||||||
where: {
|
|
||||||
id: organisation.organisationClaim.id,
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
originalSubscriptionClaimId: updatedSubscriptionClaim.id,
|
|
||||||
...createOrganisationClaimUpsertData(updatedSubscriptionClaim),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks the price metadata for a claimId, if it is missing it will fetch
|
|
||||||
* and check the product metadata for a claimId.
|
|
||||||
*
|
|
||||||
* The order of priority is:
|
|
||||||
* 1. Price metadata
|
|
||||||
* 2. Product metadata
|
|
||||||
*
|
|
||||||
* @returns The claimId or null if no claimId is found.
|
|
||||||
*/
|
|
||||||
export const extractStripeClaimId = async (priceId: Stripe.Price) => {
|
|
||||||
if (priceId.metadata.claimId) {
|
|
||||||
return priceId.metadata.claimId;
|
|
||||||
}
|
|
||||||
|
|
||||||
const productId = typeof priceId.product === 'string' ? priceId.product : priceId.product.id;
|
|
||||||
|
|
||||||
const product = await stripe.products.retrieve(productId);
|
|
||||||
|
|
||||||
return product.metadata.claimId || null;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks the price metadata for a claimId, if it is missing it will fetch
|
|
||||||
* and check the product metadata for a claimId.
|
|
||||||
*
|
|
||||||
*/
|
|
||||||
export const extractStripeClaim = async (priceId: Stripe.Price) => {
|
|
||||||
const claimId = await extractStripeClaimId(priceId);
|
|
||||||
|
|
||||||
if (!claimId) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const subscriptionClaim = await prisma.subscriptionClaim.findFirst({
|
|
||||||
where: { id: claimId },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!subscriptionClaim) {
|
|
||||||
console.error(`Subscription claim ${claimId} not found`);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return subscriptionClaim;
|
|
||||||
};
|
|
||||||
@@ -72,6 +72,14 @@ export const reportSenderRateLimit = createRateLimit({
|
|||||||
window: '7d',
|
window: '7d',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---- Billing ----
|
||||||
|
|
||||||
|
export const syncSubscriptionRateLimit = createRateLimit({
|
||||||
|
action: 'billing.sync-subscription',
|
||||||
|
max: 10,
|
||||||
|
window: '15m',
|
||||||
|
});
|
||||||
|
|
||||||
// ---- API (Tier 4 - Standard) ----
|
// ---- API (Tier 4 - Standard) ----
|
||||||
|
|
||||||
export const apiV1RateLimit = createRateLimit({
|
export const apiV1RateLimit = createRateLimit({
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { ZOrganisationNameSchema } from '@documenso/trpc/server/organisation-router/create-organisation.types';
|
|
||||||
import type { SubscriptionClaim } from '@prisma/client';
|
import type { SubscriptionClaim } from '@prisma/client';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
@@ -179,10 +178,3 @@ export const internalClaims: InternalClaims = {
|
|||||||
name: 'Early Adopter',
|
name: 'Early Adopter',
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const ZStripeOrganisationCreateMetadataSchema = z.object({
|
|
||||||
organisationName: ZOrganisationNameSchema,
|
|
||||||
userId: z.number(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export type StripeOrganisationCreateMetadata = z.infer<typeof ZStripeOrganisationCreateMetadataSchema>;
|
|
||||||
|
|||||||
@@ -1,19 +1,9 @@
|
|||||||
import type { Subscription } from '@documenso/prisma/generated/zod/modelSchema/SubscriptionSchema';
|
import type { Subscription } from '@documenso/prisma/generated/zod/modelSchema/SubscriptionSchema';
|
||||||
|
import { OrganisationType } from '@prisma/client';
|
||||||
|
|
||||||
import { IS_BILLING_ENABLED } from '../constants/app';
|
import { IS_BILLING_ENABLED } from '../constants/app';
|
||||||
import { AppError, AppErrorCode } from '../errors/app-error';
|
import { AppError, AppErrorCode } from '../errors/app-error';
|
||||||
import type { StripeOrganisationCreateMetadata } from '../types/subscription';
|
import { INTERNAL_CLAIM_ID } from '../types/subscription';
|
||||||
|
|
||||||
export const generateStripeOrganisationCreateMetadata = (organisationName: string, userId: number) => {
|
|
||||||
const metadata: StripeOrganisationCreateMetadata = {
|
|
||||||
organisationName,
|
|
||||||
userId,
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
organisationCreateData: JSON.stringify(metadata),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Throws an error if billing is enabled and no subscription is found.
|
* Throws an error if billing is enabled and no subscription is found.
|
||||||
@@ -33,3 +23,36 @@ export const validateIfSubscriptionIsRequired = (subscription?: Subscription | n
|
|||||||
|
|
||||||
return subscription;
|
return subscription;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type PendingPaymentOrganisation = {
|
||||||
|
type: OrganisationType;
|
||||||
|
subscription?: unknown;
|
||||||
|
organisationClaim: {
|
||||||
|
originalSubscriptionClaimId: string | null;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the organisation was created ahead of a paid checkout and is still awaiting
|
||||||
|
* its first successful payment.
|
||||||
|
*
|
||||||
|
* Such organisations have no subscription row and still carry the copied "free" claim,
|
||||||
|
* and must be treated as restricted until the Stripe webhook sync activates them.
|
||||||
|
*
|
||||||
|
* Always returns false when billing is disabled (self-hosted).
|
||||||
|
*/
|
||||||
|
export const isOrganisationPendingPayment = (organisation: PendingPaymentOrganisation) => {
|
||||||
|
if (!IS_BILLING_ENABLED()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (organisation.type !== OrganisationType.ORGANISATION) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (organisation.subscription) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return organisation.organisationClaim.originalSubscriptionClaimId === INTERNAL_CLAIM_ID.FREE;
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { onSubscriptionUpdated } from '@documenso/ee/server-only/stripe/webhook/on-subscription-updated';
|
import { syncStripeCustomerSubscription } from '@documenso/ee/server-only/stripe/sync-stripe-customer-subscription';
|
||||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
import { Stripe, stripe } from '@documenso/lib/server-only/stripe';
|
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
import { adminProcedure } from '../trpc';
|
import { adminProcedure } from '../trpc';
|
||||||
@@ -24,9 +23,6 @@ export const syncOrganisationSubscriptionRoute = adminProcedure
|
|||||||
|
|
||||||
const organisation = await prisma.organisation.findUnique({
|
const organisation = await prisma.organisation.findUnique({
|
||||||
where: { id: organisationId },
|
where: { id: organisationId },
|
||||||
include: {
|
|
||||||
subscription: true,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!organisation) {
|
if (!organisation) {
|
||||||
@@ -35,47 +31,14 @@ export const syncOrganisationSubscriptionRoute = adminProcedure
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!organisation.subscription) {
|
if (!organisation.customerId) {
|
||||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||||
message: 'Organisation has no subscription to sync',
|
message: 'Organisation has no Stripe customer to sync from',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let stripeSubscription: Stripe.Subscription;
|
await syncStripeCustomerSubscription({
|
||||||
|
customerId: organisation.customerId,
|
||||||
try {
|
|
||||||
stripeSubscription = await stripe.subscriptions.retrieve(organisation.subscription.planId, {
|
|
||||||
expand: ['items.data.price.product'],
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof Stripe.errors.StripeInvalidRequestError && error.code === 'resource_missing') {
|
|
||||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
|
||||||
message: 'Subscription not found on Stripe',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
const stripeCustomerId =
|
|
||||||
typeof stripeSubscription.customer === 'string' ? stripeSubscription.customer : stripeSubscription.customer.id;
|
|
||||||
|
|
||||||
if (organisation.customerId !== stripeCustomerId) {
|
|
||||||
ctx.logger.error({
|
|
||||||
message: 'Organisation customerId does not match Stripe subscription customer',
|
|
||||||
organisationId,
|
|
||||||
localCustomerId: organisation.customerId,
|
|
||||||
stripeCustomerId,
|
|
||||||
});
|
|
||||||
|
|
||||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
|
||||||
message: `Organisation customerId mismatch: local=${organisation.customerId ?? 'null'}, Stripe=${stripeCustomerId}`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
await onSubscriptionUpdated({
|
|
||||||
subscription: stripeSubscription,
|
|
||||||
previousAttributes: null,
|
|
||||||
bypassClaimUpdate: !syncClaims,
|
bypassClaimUpdate: !syncClaims,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { getPlansRoute } from './get-plans';
|
|||||||
import { getSubscriptionRoute } from './get-subscription';
|
import { getSubscriptionRoute } from './get-subscription';
|
||||||
import { linkOrganisationAccountRoute } from './link-organisation-account';
|
import { linkOrganisationAccountRoute } from './link-organisation-account';
|
||||||
import { manageSubscriptionRoute } from './manage-subscription';
|
import { manageSubscriptionRoute } from './manage-subscription';
|
||||||
|
import { syncSubscriptionRoute } from './sync-subscription';
|
||||||
import { updateOrganisationAuthenticationPortalRoute } from './update-organisation-authentication-portal';
|
import { updateOrganisationAuthenticationPortalRoute } from './update-organisation-authentication-portal';
|
||||||
import { updateOrganisationEmailRoute } from './update-organisation-email';
|
import { updateOrganisationEmailRoute } from './update-organisation-email';
|
||||||
import { verifyOrganisationEmailDomainRoute } from './verify-organisation-email-domain';
|
import { verifyOrganisationEmailDomainRoute } from './verify-organisation-email-domain';
|
||||||
@@ -48,6 +49,7 @@ export const enterpriseRouter = router({
|
|||||||
get: getSubscriptionRoute,
|
get: getSubscriptionRoute,
|
||||||
create: createSubscriptionRoute,
|
create: createSubscriptionRoute,
|
||||||
manage: manageSubscriptionRoute,
|
manage: manageSubscriptionRoute,
|
||||||
|
sync: syncSubscriptionRoute,
|
||||||
},
|
},
|
||||||
invoices: {
|
invoices: {
|
||||||
get: getInvoicesRoute,
|
get: getInvoicesRoute,
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { syncStripeCustomerSubscription } from '@documenso/ee/server-only/stripe/sync-stripe-customer-subscription';
|
||||||
|
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||||
|
import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/organisations';
|
||||||
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
|
import { assertRateLimit } from '@documenso/lib/server-only/rate-limit/rate-limit-middleware';
|
||||||
|
import { syncSubscriptionRateLimit } from '@documenso/lib/server-only/rate-limit/rate-limits';
|
||||||
|
import { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations';
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
|
import { authenticatedProcedure } from '../trpc';
|
||||||
|
import { ZSyncSubscriptionRequestSchema, ZSyncSubscriptionResponseSchema } from './sync-subscription.types';
|
||||||
|
|
||||||
|
export const syncSubscriptionRoute = authenticatedProcedure
|
||||||
|
.input(ZSyncSubscriptionRequestSchema)
|
||||||
|
.output(ZSyncSubscriptionResponseSchema)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const { organisationId } = input;
|
||||||
|
|
||||||
|
ctx.logger.info({
|
||||||
|
input: {
|
||||||
|
organisationId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const userId = ctx.user.id;
|
||||||
|
|
||||||
|
if (!IS_BILLING_ENABLED()) {
|
||||||
|
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||||
|
message: 'Billing is not enabled',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const rateLimitResult = await syncSubscriptionRateLimit.check({
|
||||||
|
ip: ctx.metadata.requestMetadata.ipAddress ?? 'unknown',
|
||||||
|
identifier: `${userId}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
assertRateLimit(rateLimitResult);
|
||||||
|
|
||||||
|
const organisation = await prisma.organisation.findFirst({
|
||||||
|
where: buildOrganisationWhereQuery({
|
||||||
|
organisationId,
|
||||||
|
userId,
|
||||||
|
roles: ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP.MANAGE_BILLING,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!organisation) {
|
||||||
|
throw new AppError(AppErrorCode.UNAUTHORIZED);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!organisation.customerId) {
|
||||||
|
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||||
|
message: 'Organisation has no billing customer',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await syncStripeCustomerSubscription({
|
||||||
|
customerId: organisation.customerId,
|
||||||
|
}).catch((error) => {
|
||||||
|
ctx.logger.error({
|
||||||
|
msg: 'Failed to sync the subscription from Stripe',
|
||||||
|
error,
|
||||||
|
});
|
||||||
|
|
||||||
|
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
|
||||||
|
message: 'Failed to sync the subscription from Stripe',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const ZSyncSubscriptionRequestSchema = z.object({
|
||||||
|
organisationId: z.string().describe('The organisation to sync the subscription for'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ZSyncSubscriptionResponseSchema = z.void();
|
||||||
|
|
||||||
|
export type TSyncSubscriptionRequest = z.infer<typeof ZSyncSubscriptionRequestSchema>;
|
||||||
|
export type TSyncSubscriptionResponse = z.infer<typeof ZSyncSubscriptionResponseSchema>;
|
||||||
@@ -5,9 +5,8 @@ import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
|||||||
import { createOrganisation } from '@documenso/lib/server-only/organisation/create-organisation';
|
import { createOrganisation } from '@documenso/lib/server-only/organisation/create-organisation';
|
||||||
import { getSubscriptionClaim } from '@documenso/lib/server-only/subscription/get-subscription-claim';
|
import { getSubscriptionClaim } from '@documenso/lib/server-only/subscription/get-subscription-claim';
|
||||||
import { INTERNAL_CLAIM_ID } from '@documenso/lib/types/subscription';
|
import { INTERNAL_CLAIM_ID } from '@documenso/lib/types/subscription';
|
||||||
import { generateStripeOrganisationCreateMetadata } from '@documenso/lib/utils/billing';
|
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
import { OrganisationType } from '@prisma/client';
|
import { OrganisationType, SubscriptionStatus } from '@prisma/client';
|
||||||
import { authenticatedProcedure } from '../trpc';
|
import { authenticatedProcedure } from '../trpc';
|
||||||
import { ZCreateOrganisationRequestSchema, ZCreateOrganisationResponseSchema } from './create-organisation.types';
|
import { ZCreateOrganisationRequestSchema, ZCreateOrganisationResponseSchema } from './create-organisation.types';
|
||||||
|
|
||||||
@@ -43,18 +42,67 @@ export const createOrganisationRoute = authenticatedProcedure
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create checkout session for payment.
|
// Create the organisation upfront, then redirect to checkout for payment.
|
||||||
|
// The webhook sync will attach the real subscription and claim after payment.
|
||||||
if (IS_BILLING_ENABLED() && priceId) {
|
if (IS_BILLING_ENABLED() && priceId) {
|
||||||
const customer = await createCustomer({
|
const pendingOrganisation = await prisma.organisation.findFirst({
|
||||||
email: user.email,
|
where: {
|
||||||
name: user.name || user.email,
|
ownerUserId: user.id,
|
||||||
|
type: OrganisationType.ORGANISATION,
|
||||||
|
OR: [
|
||||||
|
{
|
||||||
|
subscription: {
|
||||||
|
is: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
subscription: {
|
||||||
|
status: SubscriptionStatus.INACTIVE,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (pendingOrganisation) {
|
||||||
|
throw new AppError(AppErrorCode.LIMIT_EXCEEDED, {
|
||||||
|
message: 'You have a pending organisation awaiting payment. Complete or remove it before creating a new one.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const freeSubscriptionClaim = await getSubscriptionClaim(INTERNAL_CLAIM_ID.FREE);
|
||||||
|
|
||||||
|
const organisation = await createOrganisation({
|
||||||
|
userId: user.id,
|
||||||
|
name,
|
||||||
|
type: OrganisationType.ORGANISATION,
|
||||||
|
claim: freeSubscriptionClaim,
|
||||||
|
});
|
||||||
|
|
||||||
|
let customerId = organisation.customerId;
|
||||||
|
|
||||||
|
if (!customerId) {
|
||||||
|
const customer = await createCustomer({
|
||||||
|
email: user.email,
|
||||||
|
name: user.name || user.email,
|
||||||
|
});
|
||||||
|
|
||||||
|
customerId = customer.id;
|
||||||
|
|
||||||
|
await prisma.organisation.update({
|
||||||
|
where: {
|
||||||
|
id: organisation.id,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
customerId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const checkoutUrl = await createCheckoutSession({
|
const checkoutUrl = await createCheckoutSession({
|
||||||
priceId,
|
priceId,
|
||||||
customerId: customer.id,
|
customerId,
|
||||||
returnUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/settings/organisations`,
|
returnUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/o/${organisation.url}/settings/billing`,
|
||||||
subscriptionMetadata: generateStripeOrganisationCreateMetadata(name, user.id),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
Reference in New Issue
Block a user