Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { CRON_BATCH_SIZE, qstash } from "@/lib/cron";
import { enqueueBatchJobs } from "@/lib/cron/enqueue-batch-jobs";
import { withCron } from "@/lib/cron/with-cron";
import { isDiscountIntegrationNotAvailableError } from "@/lib/discounts/discount-error";
import { isNonRecoverableDiscountError } from "@/lib/discounts/discount-error";
import { getDiscountProvider } from "@/lib/discounts/discount-provider";
import { prisma } from "@/lib/prisma";
import { ACTIVE_ENROLLMENT_STATUSES } from "@/lib/zod/schemas/partners";
Expand Down Expand Up @@ -55,14 +55,12 @@ export const POST = withCron(async ({ rawBody }) => {
const discountProvider = getDiscountProvider(discount.provider);

try {
await discountProvider.assertDiscountIntegrationAvailable({
await discountProvider.assertDiscountIntegration({
workspace: program.workspace,
});
} catch (error) {
if (isDiscountIntegrationNotAvailableError(error)) {
return logAndRespond(
`Workspace has not installed the ${discount.provider} integration. Skipping...`,
);
if (isNonRecoverableDiscountError(error)) {
return logAndRespond(error.message, { logLevel: "warn" });
}

throw error;
Expand Down
17 changes: 2 additions & 15 deletions apps/web/app/(ee)/api/cron/discount-codes/create/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { withCron } from "@/lib/cron/with-cron";
import { createDiscountCode } from "@/lib/discounts/create-discount-code";
import { isDiscountIntegrationNotAvailableError } from "@/lib/discounts/discount-error";
import { isNonRecoverableDiscountError } from "@/lib/discounts/discount-error";
import { prisma } from "@/lib/prisma";
import * as z from "zod/v4";
import { logAndRespond } from "../../utils";
Expand Down Expand Up @@ -90,20 +90,7 @@ export const POST = withCron(async ({ rawBody }) => {
discount,
});
} catch (error) {
if (isDiscountIntegrationNotAvailableError(error)) {
return logAndRespond(
`Workspace has not installed the ${discount.provider} integration. Skipping...`,
);
}

// Eg: This application does not have the required permissions for this endpoint on account 'acct_xxx'.
// Having the 'read_write' scope would allow this request to continue.
if (
error instanceof Error &&
error.message.includes(
"This application does not have the required permissions",
)
) {
if (isNonRecoverableDiscountError(error)) {
return logAndRespond(error.message, { logLevel: "warn" });
}

Expand Down
8 changes: 5 additions & 3 deletions apps/web/app/(ee)/api/cron/discount-codes/disable/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { withCron } from "@/lib/cron/with-cron";
import { isDiscountIntegrationNotAvailableError } from "@/lib/discounts/discount-error";
import { isNonRecoverableDiscountError } from "@/lib/discounts/discount-error";
import { getDiscountProvider } from "@/lib/discounts/discount-provider";
import { prisma } from "@/lib/prisma";
import { DiscountProvider } from "@prisma/client";
Expand Down Expand Up @@ -37,8 +37,10 @@ export const POST = withCron(async ({ rawBody }) => {
code,
});
} catch (error) {
if (isDiscountIntegrationNotAvailableError(error)) {
return logAndRespond(`Skipping ${code}: ${error.message}`);
if (isNonRecoverableDiscountError(error)) {
return logAndRespond(`Skipping ${code}: ${error.message}`, {
logLevel: "warn",
});
}

throw error;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { withCron } from "@/lib/cron/with-cron";
import { createDiscountCode } from "@/lib/discounts/create-discount-code";
import { deleteDiscountCodes } from "@/lib/discounts/delete-discount-code";
import { isDiscountIntegrationNotAvailableError } from "@/lib/discounts/discount-error";
import { isNonRecoverableDiscountError } from "@/lib/discounts/discount-error";
import { isDiscountEquivalent } from "@/lib/discounts/is-discount-equivalent";
import { prisma } from "@/lib/prisma";
import { Discount, DiscountCode } from "@prisma/client";
Expand Down Expand Up @@ -164,9 +164,9 @@ export const POST = withCron(async ({ rawBody }) => {
discount: group.discount,
});
} catch (error) {
if (isDiscountIntegrationNotAvailableError(error)) {
if (isNonRecoverableDiscountError(error)) {
console.warn(
`Workspace has not installed the ${group.discount.provider} integration. Skipping remaining discount code creation for remap.`,
`${error.message} Skipping remaining discount code creation for remap.`,
);
break;
}
Expand Down
2 changes: 1 addition & 1 deletion apps/web/lib/actions/partners/create-discount.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ export const createDiscountAction = authActionClient
});
}
} else if (provider === DiscountProvider.shopify) {
await discountProvider.assertDiscountIntegrationAvailable({
await discountProvider.assertDiscountIntegration({
workspace,
});
}
Expand Down
33 changes: 6 additions & 27 deletions apps/web/lib/discounts/create-discount-code.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,33 +41,12 @@ export async function createDiscountCode({

const discountProvider = getDiscountProvider(discount.provider);

let externalDiscountCode: Awaited<
ReturnType<typeof discountProvider.createDiscountCode>
>;

try {
externalDiscountCode = await discountProvider.createDiscountCode({
workspace,
discount,
code: finalCode,
shouldRetry: code ? false : true,
});
} catch (error) {
const message = error?.raw?.message || error?.message || "";
const isDuplicateCode =
message.includes("already exists") ||
error?.code === "TAKEN" ||
error?.code === "DUPLICATE";

if (isDuplicateCode) {
throw new DubApiError({
code: "conflict",
message: `The discount code ${finalCode} is already in use. Please choose a different code.`,
});
}

throw error;
}
const externalDiscountCode = await discountProvider.createDiscountCode({
workspace,
discount,
code: finalCode,
shouldRetry: code ? false : true,
});

try {
return await prisma.discountCode.create({
Expand Down
69 changes: 62 additions & 7 deletions apps/web/lib/discounts/discount-error.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,70 @@
import { DubApiError } from "../api/errors";

export class DiscountIntegrationNotAvailableError extends DubApiError {
constructor({ message }: { message: string }) {
super({ code: "bad_request", message });
this.name = "DiscountIntegrationNotAvailableError";
export type DiscountProviderErrorCode =
| "INTEGRATION_NOT_AVAILABLE"
| "DISCOUNT_ALREADY_EXISTS"
| "COUPON_NOT_FOUND"
| "PERMISSIONS_REQUIRED"
| "CREATE_FAILED";

const API_CODE_BY_PROVIDER_CODE: Record<
DiscountProviderErrorCode,
"bad_request" | "conflict" | "internal_server_error"
> = {
INTEGRATION_NOT_AVAILABLE: "bad_request",
DISCOUNT_ALREADY_EXISTS: "conflict",
COUPON_NOT_FOUND: "bad_request",
PERMISSIONS_REQUIRED: "bad_request",
CREATE_FAILED: "internal_server_error",
};

function resolveDiscountProviderMessage(
provider: "stripe" | "shopify",
providerCode: DiscountProviderErrorCode,
message: string,
): string {
if (providerCode === "INTEGRATION_NOT_AVAILABLE") {
return provider === "stripe"
? "STRIPE_CONNECTION_REQUIRED: Your workspace isn't connected to Stripe yet. Please install the Dub Stripe app in settings to create a discount."
: "SHOPIFY_CONNECTION_REQUIRED: Your workspace isn't connected to Shopify yet. Please install the Dub Shopify app in settings to create a discount.";
}

if (providerCode === "PERMISSIONS_REQUIRED") {
return provider === "stripe"
? "STRIPE_APP_UPGRADE_REQUIRED: Your connected Stripe account doesn't have the permissions needed to create discount codes. Please upgrade your Stripe integration in settings or reach out to our support team for help."
: "SHOPIFY_APP_UPGRADE_REQUIRED: Your connected Shopify store doesn't have permission to create discount codes. Please reinstall or upgrade the Dub Shopify app.";
}

return message;
}

export class DiscountProviderError extends DubApiError {
constructor(
public readonly provider: "stripe" | "shopify",
public readonly providerCode: DiscountProviderErrorCode,
message: string,
) {
super({
code: API_CODE_BY_PROVIDER_CODE[providerCode],
message: resolveDiscountProviderMessage(provider, providerCode, message),
});
this.name = "DiscountProviderError";
Object.setPrototypeOf(this, new.target.prototype);
}

get isRecoverable() {
return this.providerCode === "CREATE_FAILED";
}
}

export function isDiscountProviderError(
error: unknown,
): error is DiscountProviderError {
return error instanceof DiscountProviderError;
}

export function isDiscountIntegrationNotAvailableError(
export function isNonRecoverableDiscountError(
error: unknown,
): error is DiscountIntegrationNotAvailableError {
return error instanceof DiscountIntegrationNotAvailableError;
): error is DiscountProviderError {
return isDiscountProviderError(error) && !error.isRecoverable;
}
102 changes: 74 additions & 28 deletions apps/web/lib/discounts/discount-provider-shopify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
shopifyAdminGraphql,
} from "../integrations/shopify/admin-graphql";
import { integrationCredentialsSchema } from "../integrations/shopify/schema";
import { DiscountIntegrationNotAvailableError } from "./discount-error";
import { DiscountProviderError } from "./discount-error";

interface ShopifyDiscountCodeBasicCreate {
codeDiscountNode: {
Expand Down Expand Up @@ -38,10 +38,11 @@ async function requireInstalledIntegration(
workspace: Pick<Project, "id" | "shopifyStoreId">,
) {
if (!workspace.shopifyStoreId) {
throw new DiscountIntegrationNotAvailableError({
message:
"SHOPIFY_CONNECTION_REQUIRED: Your workspace isn't connected to Shopify yet. Please install the Dub Shopify app in settings to create a discount.",
});
throw new DiscountProviderError(
"shopify",
"INTEGRATION_NOT_AVAILABLE",
"SHOPIFY_CONNECTION_REQUIRED: Your workspace isn't connected to Shopify yet. Please install the Dub Shopify app in settings to create a discount.",
);
}

const installation = await prisma.installedIntegration.findFirst({
Expand All @@ -52,21 +53,23 @@ async function requireInstalledIntegration(
});

if (!installation) {
throw new DiscountIntegrationNotAvailableError({
message:
"SHOPIFY_CONNECTION_REQUIRED: Your workspace isn't connected to Shopify yet. Please install the Dub Shopify app in settings to create a discount.",
});
throw new DiscountProviderError(
"shopify",
"INTEGRATION_NOT_AVAILABLE",
"SHOPIFY_CONNECTION_REQUIRED: Your workspace isn't connected to Shopify yet. Please install the Dub Shopify app in settings to create a discount.",
);
}

let credentials = integrationCredentialsSchema.parse(
installation.credentials || {},
);

if (!credentials?.scope?.includes("write_discounts")) {
throw new DiscountIntegrationNotAvailableError({
message:
"SHOPIFY_APP_UPGRADE_REQUIRED: Your connected Shopify store doesn't have permission to create discount codes. Please reinstall or upgrade the Dub Shopify app.",
});
throw new DiscountProviderError(
"shopify",
"PERMISSIONS_REQUIRED",
"SHOPIFY_APP_UPGRADE_REQUIRED: Your connected Shopify store doesn't have permission to create discount codes. Please reinstall or upgrade the Dub Shopify app.",
);
}

return {
Expand Down Expand Up @@ -187,7 +190,7 @@ function createShopifyDiscountProvider() {
if (!codeDiscountNode) {
throw new ShopifyAdminGraphqlError(
"no_node_returned",
"Shopify did not return a discount code node.",
"Shopify did not return a discount code. Please try again.",
);
}

Expand All @@ -199,27 +202,70 @@ function createShopifyDiscountProvider() {
error instanceof ShopifyAdminGraphqlError &&
(error.code === "TAKEN" || error.code === "DUPLICATE");

if (!isDuplicate || !shouldRetry) {
throw error;
}
if (isDuplicate) {
if (!shouldRetry) {
throw new DiscountProviderError(
"shopify",
"DISCOUNT_ALREADY_EXISTS",
`The discount code ${currentCode} is already in use. Please choose a different code.`,
);
}

attempt++;
attempt++;

if (attempt >= MAX_ATTEMPTS) {
throw new DiscountProviderError(
"shopify",
"DISCOUNT_ALREADY_EXISTS",
`The discount code ${currentCode} is already in use. Please choose a different code.`,
);
}

if (attempt >= MAX_ATTEMPTS) {
throw error;
const newCode = `${currentCode}${nanoid(2)}`;

console.warn(
`Discount code "${currentCode}" already exists in Shopify. Retrying with "${newCode}" (attempt ${attempt}/${MAX_ATTEMPTS}).`,
);

currentCode = newCode;
continue;
}

const newCode = `${currentCode}${nanoid(2)}`;
if (error instanceof ShopifyAdminGraphqlError) {
if (error.code === "unauthorized") {
throw new DiscountProviderError(
"shopify",
"PERMISSIONS_REQUIRED",
error.message,
);
}

console.warn(
`Discount code "${currentCode}" already exists in Shopify. Retrying with "${newCode}" (attempt ${attempt}/${MAX_ATTEMPTS}).`,
);
throw new DiscountProviderError(
"shopify",
"CREATE_FAILED",
error.code === "no_node_returned"
? "Shopify did not return a discount code. Please try again."
: error.code === "http_error" || error.code === "graphql_error"
? `Unable to create the discount code in Shopify. ${error.message}`
: error.message,
);
}

currentCode = newCode;
throw new DiscountProviderError(
"shopify",
"CREATE_FAILED",
error instanceof Error
? error.message
: "Failed to create Shopify discount code.",
);
}
}

throw new Error("Failed to create Shopify discount code.");
throw new DiscountProviderError(
"shopify",
"CREATE_FAILED",
"Failed to create Shopify discount code.",
);
};

const disableDiscountCode = async ({
Expand Down Expand Up @@ -292,7 +338,7 @@ function createShopifyDiscountProvider() {
return { id, code };
};

const assertDiscountIntegrationAvailable = async ({
const assertDiscountIntegration = async ({
workspace,
}: {
workspace: Pick<Project, "id" | "stripeConnectId" | "shopifyStoreId">;
Expand All @@ -305,7 +351,7 @@ function createShopifyDiscountProvider() {
createCoupon,
createDiscountCode,
disableDiscountCode,
assertDiscountIntegrationAvailable,
assertDiscountIntegration,
};
}

Expand Down
Loading
Loading