Skip to content

Commit bd99da2

Browse files
authored
Improve discount creation error handling across Stripe and Shopify (#4173)
1 parent 9effecf commit bd99da2

10 files changed

Lines changed: 372 additions & 200 deletions

File tree

apps/web/app/(ee)/api/cron/discount-codes/create/queue-batches/route.ts

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { CRON_BATCH_SIZE, qstash } from "@/lib/cron";
22
import { enqueueBatchJobs } from "@/lib/cron/enqueue-batch-jobs";
33
import { withCron } from "@/lib/cron/with-cron";
4-
import { isDiscountIntegrationNotAvailableError } from "@/lib/discounts/discount-error";
4+
import { isNonRecoverableDiscountError } from "@/lib/discounts/discount-error";
55
import { getDiscountProvider } from "@/lib/discounts/discount-provider";
66
import { prisma } from "@/lib/prisma";
77
import { ACTIVE_ENROLLMENT_STATUSES } from "@/lib/zod/schemas/partners";
@@ -55,14 +55,12 @@ export const POST = withCron(async ({ rawBody }) => {
5555
const discountProvider = getDiscountProvider(discount.provider);
5656

5757
try {
58-
await discountProvider.assertDiscountIntegrationAvailable({
58+
await discountProvider.assertDiscountIntegration({
5959
workspace: program.workspace,
6060
});
6161
} catch (error) {
62-
if (isDiscountIntegrationNotAvailableError(error)) {
63-
return logAndRespond(
64-
`Workspace has not installed the ${discount.provider} integration. Skipping...`,
65-
);
62+
if (isNonRecoverableDiscountError(error)) {
63+
return logAndRespond(error.message, { logLevel: "warn" });
6664
}
6765

6866
throw error;

apps/web/app/(ee)/api/cron/discount-codes/create/route.ts

Lines changed: 2 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { withCron } from "@/lib/cron/with-cron";
22
import { createDiscountCode } from "@/lib/discounts/create-discount-code";
3-
import { isDiscountIntegrationNotAvailableError } from "@/lib/discounts/discount-error";
3+
import { isNonRecoverableDiscountError } from "@/lib/discounts/discount-error";
44
import { prisma } from "@/lib/prisma";
55
import * as z from "zod/v4";
66
import { logAndRespond } from "../../utils";
@@ -90,20 +90,7 @@ export const POST = withCron(async ({ rawBody }) => {
9090
discount,
9191
});
9292
} catch (error) {
93-
if (isDiscountIntegrationNotAvailableError(error)) {
94-
return logAndRespond(
95-
`Workspace has not installed the ${discount.provider} integration. Skipping...`,
96-
);
97-
}
98-
99-
// Eg: This application does not have the required permissions for this endpoint on account 'acct_xxx'.
100-
// Having the 'read_write' scope would allow this request to continue.
101-
if (
102-
error instanceof Error &&
103-
error.message.includes(
104-
"This application does not have the required permissions",
105-
)
106-
) {
93+
if (isNonRecoverableDiscountError(error)) {
10794
return logAndRespond(error.message, { logLevel: "warn" });
10895
}
10996

apps/web/app/(ee)/api/cron/discount-codes/disable/route.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { withCron } from "@/lib/cron/with-cron";
2-
import { isDiscountIntegrationNotAvailableError } from "@/lib/discounts/discount-error";
2+
import { isNonRecoverableDiscountError } from "@/lib/discounts/discount-error";
33
import { getDiscountProvider } from "@/lib/discounts/discount-provider";
44
import { prisma } from "@/lib/prisma";
55
import { DiscountProvider } from "@prisma/client";
@@ -37,8 +37,10 @@ export const POST = withCron(async ({ rawBody }) => {
3737
code,
3838
});
3939
} catch (error) {
40-
if (isDiscountIntegrationNotAvailableError(error)) {
41-
return logAndRespond(`Skipping ${code}: ${error.message}`);
40+
if (isNonRecoverableDiscountError(error)) {
41+
return logAndRespond(`Skipping ${code}: ${error.message}`, {
42+
logLevel: "warn",
43+
});
4244
}
4345

4446
throw error;

apps/web/app/(ee)/api/cron/groups/remap-discount-codes/route.ts

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { withCron } from "@/lib/cron/with-cron";
22
import { createDiscountCode } from "@/lib/discounts/create-discount-code";
33
import { deleteDiscountCodes } from "@/lib/discounts/delete-discount-code";
4-
import { isDiscountIntegrationNotAvailableError } from "@/lib/discounts/discount-error";
4+
import { isDiscountProviderError } from "@/lib/discounts/discount-error";
55
import { isDiscountEquivalent } from "@/lib/discounts/is-discount-equivalent";
66
import { prisma } from "@/lib/prisma";
77
import { Discount, DiscountCode } from "@prisma/client";
@@ -164,13 +164,19 @@ export const POST = withCron(async ({ rawBody }) => {
164164
discount: group.discount,
165165
});
166166
} catch (error) {
167-
if (isDiscountIntegrationNotAvailableError(error)) {
168-
console.warn(
169-
`Workspace has not installed the ${group.discount.provider} integration. Skipping remaining discount code creation for remap.`,
170-
);
171-
break;
167+
if (isDiscountProviderError(error)) {
168+
if (
169+
error.providerCode === "INTEGRATION_NOT_AVAILABLE" ||
170+
error.providerCode === "AUTH_EXPIRED" ||
171+
error.providerCode === "PERMISSIONS_REQUIRED" ||
172+
error.providerCode === "COUPON_NOT_FOUND"
173+
) {
174+
console.warn(
175+
`${error.message} Skipping remaining discount code creation for remap.`,
176+
);
177+
break;
178+
}
172179
}
173-
174180
throw error;
175181
}
176182
}

apps/web/lib/actions/partners/create-discount.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ export const createDiscountAction = authActionClient
6767
});
6868
}
6969
} else if (provider === DiscountProvider.shopify) {
70-
await discountProvider.assertDiscountIntegrationAvailable({
70+
await discountProvider.assertDiscountIntegration({
7171
workspace,
7272
});
7373
}

apps/web/lib/discounts/create-discount-code.ts

Lines changed: 6 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -41,33 +41,12 @@ export async function createDiscountCode({
4141

4242
const discountProvider = getDiscountProvider(discount.provider);
4343

44-
let externalDiscountCode: Awaited<
45-
ReturnType<typeof discountProvider.createDiscountCode>
46-
>;
47-
48-
try {
49-
externalDiscountCode = await discountProvider.createDiscountCode({
50-
workspace,
51-
discount,
52-
code: finalCode,
53-
shouldRetry: code ? false : true,
54-
});
55-
} catch (error) {
56-
const message = error?.raw?.message || error?.message || "";
57-
const isDuplicateCode =
58-
message.includes("already exists") ||
59-
error?.code === "TAKEN" ||
60-
error?.code === "DUPLICATE";
61-
62-
if (isDuplicateCode) {
63-
throw new DubApiError({
64-
code: "conflict",
65-
message: `The discount code ${finalCode} is already in use. Please choose a different code.`,
66-
});
67-
}
68-
69-
throw error;
70-
}
44+
const externalDiscountCode = await discountProvider.createDiscountCode({
45+
workspace,
46+
discount,
47+
code: finalCode,
48+
shouldRetry: code ? false : true,
49+
});
7150

7251
try {
7352
return await prisma.discountCode.create({
Lines changed: 70 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,78 @@
11
import { DubApiError } from "../api/errors";
22

3-
export class DiscountIntegrationNotAvailableError extends DubApiError {
4-
constructor({ message }: { message: string }) {
5-
super({ code: "bad_request", message });
6-
this.name = "DiscountIntegrationNotAvailableError";
3+
export type DiscountProviderErrorCode =
4+
| "INTEGRATION_NOT_AVAILABLE"
5+
| "AUTH_EXPIRED"
6+
| "DISCOUNT_ALREADY_EXISTS"
7+
| "COUPON_NOT_FOUND"
8+
| "PERMISSIONS_REQUIRED"
9+
| "CREATE_FAILED";
10+
11+
const API_CODE_BY_PROVIDER_CODE: Record<
12+
DiscountProviderErrorCode,
13+
"bad_request" | "conflict" | "internal_server_error"
14+
> = {
15+
INTEGRATION_NOT_AVAILABLE: "bad_request",
16+
AUTH_EXPIRED: "bad_request",
17+
DISCOUNT_ALREADY_EXISTS: "conflict",
18+
COUPON_NOT_FOUND: "bad_request",
19+
PERMISSIONS_REQUIRED: "bad_request",
20+
CREATE_FAILED: "internal_server_error",
21+
};
22+
23+
function resolveDiscountProviderMessage(
24+
provider: "stripe" | "shopify",
25+
providerCode: DiscountProviderErrorCode,
26+
message: string,
27+
): string {
28+
if (providerCode === "INTEGRATION_NOT_AVAILABLE") {
29+
return provider === "stripe"
30+
? "STRIPE_CONNECTION_REQUIRED: Your workspace isn't connected to Stripe yet. Please install the Dub Stripe app in settings to create a discount."
31+
: "SHOPIFY_CONNECTION_REQUIRED: Your workspace isn't connected to Shopify yet. Please install the Dub Shopify app in settings to create a discount.";
32+
}
33+
34+
if (providerCode === "AUTH_EXPIRED") {
35+
return provider === "stripe"
36+
? "STRIPE_RECONNECT_REQUIRED: Your Stripe connection has expired or been revoked. Please reconnect the Dub Stripe app in settings."
37+
: "SHOPIFY_RECONNECT_REQUIRED: Your Shopify connection has expired or been revoked. Please reconnect the Dub Shopify app in settings.";
38+
}
39+
40+
if (providerCode === "PERMISSIONS_REQUIRED") {
41+
return provider === "stripe"
42+
? "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."
43+
: "SHOPIFY_APP_UPGRADE_REQUIRED: Your connected Shopify store doesn't have permission to create discount codes. Please reinstall or upgrade the Dub Shopify app.";
44+
}
45+
46+
return message;
47+
}
48+
49+
export class DiscountProviderError extends DubApiError {
50+
constructor(
51+
public readonly provider: "stripe" | "shopify",
52+
public readonly providerCode: DiscountProviderErrorCode,
53+
message: string,
54+
) {
55+
super({
56+
code: API_CODE_BY_PROVIDER_CODE[providerCode],
57+
message: resolveDiscountProviderMessage(provider, providerCode, message),
58+
});
59+
this.name = "DiscountProviderError";
760
Object.setPrototypeOf(this, new.target.prototype);
861
}
62+
63+
get isRecoverable() {
64+
return this.providerCode === "CREATE_FAILED";
65+
}
66+
}
67+
68+
export function isDiscountProviderError(
69+
error: unknown,
70+
): error is DiscountProviderError {
71+
return error instanceof DiscountProviderError;
972
}
1073

11-
export function isDiscountIntegrationNotAvailableError(
74+
export function isNonRecoverableDiscountError(
1275
error: unknown,
13-
): error is DiscountIntegrationNotAvailableError {
14-
return error instanceof DiscountIntegrationNotAvailableError;
76+
): error is DiscountProviderError {
77+
return isDiscountProviderError(error) && !error.isRecoverable;
1578
}

0 commit comments

Comments
 (0)