From bd59a3dbd85b77261fe79a159e28a6890d1e614e Mon Sep 17 00:00:00 2001 From: trevor-anderson <43518091+trevor-anderson@users.noreply.github.com> Date: Sat, 22 Jun 2024 14:21:36 -0400 Subject: [PATCH] feat: add auth method verifyUserIsAuthorizedToAccessPaidContent --- ...serIsAuthorizedToAccessPaidContent.test.ts | 61 +++++++++++++ ...rifyUserIsAuthorizedToAccessPaidContent.ts | 87 +++++++++++++++++++ 2 files changed, 148 insertions(+) create mode 100644 src/services/AuthService/verifyUserIsAuthorizedToAccessPaidContent.test.ts create mode 100644 src/services/AuthService/verifyUserIsAuthorizedToAccessPaidContent.ts diff --git a/src/services/AuthService/verifyUserIsAuthorizedToAccessPaidContent.test.ts b/src/services/AuthService/verifyUserIsAuthorizedToAccessPaidContent.test.ts new file mode 100644 index 0000000..7a8c053 --- /dev/null +++ b/src/services/AuthService/verifyUserIsAuthorizedToAccessPaidContent.test.ts @@ -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 + ): 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"); + }); +}); diff --git a/src/services/AuthService/verifyUserIsAuthorizedToAccessPaidContent.ts b/src/services/AuthService/verifyUserIsAuthorizedToAccessPaidContent.ts new file mode 100644 index 0000000..99ef974 --- /dev/null +++ b/src/services/AuthService/verifyUserIsAuthorizedToAccessPaidContent.ts @@ -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 + }, +};