Skip to content

Commit

Permalink
feat: add auth method verifyUserIsAuthorizedToAccessPaidContent
Browse files Browse the repository at this point in the history
  • Loading branch information
trevor-anderson committed Jun 22, 2024
1 parent e152ba2 commit bd59a3d
Show file tree
Hide file tree
Showing 2 changed files with 148 additions and 0 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { MOCK_USERS, MOCK_USER_SCAs } from "@/tests/staticMockItems";
import { verifyUserIsAuthorizedToAccessPaidContent } from "./verifyUserIsAuthorizedToAccessPaidContent.js";
import type { UserSubscription } from "@/types/graphql.js";
import type { AuthTokenPayload } from "@/types/open-api.js";

describe("verifyUserIsAuthorizedToAccessPaidContent()", () => {
const YEAR_2000 = new Date(2000, 0);
const YEAR_9999 = new Date(9999, 0);

const mockUserWithSubFields = (
subFieldsToTest: Pick<UserSubscription, "status" | "currentPeriodEnd">
): AuthTokenPayload => ({
...MOCK_USERS.USER_A,
stripeConnectAccount: MOCK_USER_SCAs.SCA_A,
subscription: { id: "", ...subFieldsToTest },
});

test(`does not throw when called with a valid "active" subscription`, () => {
expect(() => {
verifyUserIsAuthorizedToAccessPaidContent({
authenticatedUser: mockUserWithSubFields({
status: "active",
currentPeriodEnd: YEAR_9999,
}),
});
}).not.toThrow();
});

test(`does not throw when called with a valid "trialing" subscription`, () => {
expect(() => {
verifyUserIsAuthorizedToAccessPaidContent({
authenticatedUser: mockUserWithSubFields({
status: "trialing",
currentPeriodEnd: YEAR_9999,
}),
});
}).not.toThrow();
});

test(`throws an error when called with a subscription with an invalid status`, () => {
expect(() => {
verifyUserIsAuthorizedToAccessPaidContent({
authenticatedUser: mockUserWithSubFields({
status: "past_due",
currentPeriodEnd: YEAR_9999,
}),
});
}).toThrow("past due");
});

test(`throws an error when called with an expired subscription`, () => {
expect(() => {
verifyUserIsAuthorizedToAccessPaidContent({
authenticatedUser: mockUserWithSubFields({
status: "active",
currentPeriodEnd: YEAR_2000,
}),
});
}).toThrow("expired");
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { hasKey } from "@nerdware/ts-type-safety-utils";
import dayjs from "dayjs";
import { PaymentRequiredError } from "@/utils/httpErrors.js";
import type { SubscriptionStatus } from "@/types/graphql.js";
import type { AuthTokenPayload } from "@/types/open-api.js";

/**
* ### AuthService: Verify User Is Authorized To Access Paid Content
*
* Verify whether a User's existing subscription is valid for service-access.
*
* A UserSubscription is only considered valid for service-access if all of the following are true:
*
* 1. The subscription's status is `active` or `trialing`.
* 2. The subscription's `currentPeriodEnd` timestamp is in the future.
*/
export const verifyUserIsAuthorizedToAccessPaidContent = ({
authenticatedUser,
}: {
authenticatedUser: AuthTokenPayload;
}) => {
// Destructure necessary fields
const { status, currentPeriodEnd } = authenticatedUser.subscription ?? {};

if (
!status ||
!hasKey(SUB_STATUS_AUTH_METADATA, status) ||
SUB_STATUS_AUTH_METADATA[status]?.isValid !== true ||
!currentPeriodEnd ||
!dayjs(currentPeriodEnd).isValid()
) {
throw new Error(
!!status && hasKey(SUB_STATUS_AUTH_METADATA, status)
? SUB_STATUS_AUTH_METADATA[status]?.reason ?? "Invalid subscription."
: "Invalid subscription."
);
}

// Coerce to unix timestamp in seconds and compare
if (dayjs().unix() >= dayjs(currentPeriodEnd).unix()) {
throw new PaymentRequiredError(
"This subscription has expired — please update your payment settings to re-activate your subscription."
);
}
};

/**
* Subscription-Status authorization metadata objects:
*
* - Subscription statuses which indicate a sub IS VALID for Fixit service usage
* contain `isValid: true`.
* - Subscription statuses which indicate a sub IS NOT VALID for Fixit service
* usage contain a `reason` plainly explaining to end users _why_ their sub is
* invalid, and what their course of action should be to resolve the issue.
*
* @see https://stripe.com/docs/api/subscriptions/object#subscription_object-status
*/
export const SUB_STATUS_AUTH_METADATA: Readonly<
Record<
SubscriptionStatus,
{ isValid: true; reason?: never } | { isValid?: false; reason: string }
>
> = {
// Statuses which indicate a subscription IS VALID for service usage:
active: {
isValid: true,
},
trialing: {
isValid: true,
},
// Statuses which indicate a subscription IS NOT VALID for service usage:
incomplete: {
reason: "Sorry, your subscription payment is incomplete.",
},
incomplete_expired: {
reason: "Sorry, please try again.",
},
past_due: {
reason: "Sorry, payment for your subscription is past due. Please submit payment and try again.", // prettier-ignore
},
canceled: {
reason: "Sorry, this subscription was canceled.",
},
unpaid: {
reason: "Sorry, payment for your subscription is past due. Please submit payment and try again.", // prettier-ignore
},
};

0 comments on commit bd59a3d

Please sign in to comment.