fix: referral program coupon reward, apply to redeemer

This commit is contained in:
John Yeo
2025-09-22 10:40:49 +01:00
91 changed files with 22795 additions and 5330 deletions

2
.gitignore vendored
View File

@@ -107,4 +107,4 @@ migration.sh
stat.sh
CLAUDE.md
interview
interview

View File

@@ -15,7 +15,10 @@
"linter": {
"enabled": true,
"rules": {
"recommended": true
"recommended": true,
"complexity": {
"noStaticOnlyClass": "off"
}
}
},
"javascript": {

15047
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -26,6 +26,9 @@
"noExportsInTest": "off",
"noExplicitAny": "off",
"noImplicitAnyLet": "off"
},
"complexity": {
"noStaticOnlyClass": "off"
}
}
},

View File

@@ -7,23 +7,20 @@ source "$(dirname "$0")/config.sh"
if [[ "$1" == *"setup"* ]]; then
MOCHA_PARALLEL=true $MOCHA_SETUP
fi
# $MOCHA_CMD 'tests/advanced/multiFeature/*.ts'
$MOCHA_CMD 'tests/advanced/referrals/*.ts' 'tests/advanced/coupons/*.ts'
$MOCHA_CMD 'tests/advanced/multiFeature/*.ts' \
'tests/advanced/coupons/*.ts' \
'tests/attach/updateQuantity/*.ts' \
'tests/advanced/referrals/*.ts' \
'tests/advanced/rollovers/*.ts' \
'tests/advanced/customInterval/*.ts'
$MOCHA_CMD 'tests/advanced/referrals/*.ts' \
'tests/advanced/rollovers/*.ts' \
'tests/advanced/customInterval/*.ts'
# $MOCHA_CMD 'tests/advanced/multiFeature/*.ts' \
# 'tests/advanced/coupons/*.ts' \
# 'tests/attach/updateQuantity/*.ts' \
# 'tests/advanced/referrals/*.ts' \
# 'tests/advanced/referrals/paid/*.ts' \
# 'tests/advanced/rollovers/*.ts' \
# 'tests/advanced/customInterval/*.ts'
$MOCHA_CMD 'tests/attach/multiProduct/*.ts' \
'tests/advanced/usageLimit/*.ts'
# $MOCHA_CMD 'tests/attach/multiProduct/*.ts' \
# 'tests/advanced/usageLimit/*.ts'
$MOCHA_CMD 'tests/advanced/usage/*.ts'
# $MOCHA_CMD 'tests/advanced/usage/*.ts'

View File

@@ -1,497 +1,497 @@
import dotenv from "dotenv";
dotenv.config();
import { toSnakeCase } from "@/utils/genUtils.js";
import {
APIVersion,
CreateEntity,
CreateRewardProgram,
CusExpand,
EntityExpand,
ErrCode,
Invoice,
OrgConfig,
type APIVersion,
type AttachBody,
type CreateEntity,
type CreateRewardProgram,
CusExpand,
EntityExpand,
ErrCode,
type OrgConfig,
type RewardRedemption,
} from "@autumn/shared";
import {
CancelParams,
CheckoutParams,
CheckoutResult,
CheckParams,
CheckResult,
Customer,
TrackParams,
TransferProductParams,
UsageParams,
import type {
CancelParams,
CheckoutParams,
CheckoutResult,
CheckParams,
CheckResult,
Customer,
TrackParams,
UsageParams,
} from "autumn-js";
import { AttachBody } from "@autumn/shared";
export default class AutumnError extends Error {
message: string;
code: string;
message: string;
code: string;
constructor({ message, code }: { message: string; code: string }) {
super(message);
this.message = message;
this.code = code;
}
constructor({ message, code }: { message: string; code: string }) {
super(message);
this.message = message;
this.code = code;
}
toString(): string {
return `${this.message} (code: ${this.code})`;
}
toString(): string {
return `${this.message} (code: ${this.code})`;
}
}
export class AutumnInt {
private apiKey: string;
public headers: Record<string, string>;
public baseUrl: string;
private apiKey: string;
public headers: Record<string, string>;
public baseUrl: string;
constructor({
apiKey,
secretKey,
baseUrl,
version,
orgConfig,
}: {
apiKey?: string;
secretKey?: string;
baseUrl?: string;
version?: string | APIVersion;
orgConfig?: Partial<OrgConfig>;
} = {}) {
// this.apiKey = apiKey || process.env.AUTUMN_API_KEY || "";
this.apiKey =
apiKey || secretKey || process.env.UNIT_TEST_AUTUMN_SECRET_KEY || "";
constructor({
apiKey,
secretKey,
baseUrl,
version,
orgConfig,
}: {
apiKey?: string;
secretKey?: string;
baseUrl?: string;
version?: string | APIVersion;
orgConfig?: Partial<OrgConfig>;
} = {}) {
// this.apiKey = apiKey || process.env.AUTUMN_API_KEY || "";
this.apiKey =
apiKey || secretKey || process.env.UNIT_TEST_AUTUMN_SECRET_KEY || "";
this.headers = {
Authorization: `Bearer ${this.apiKey}`,
"Content-Type": "application/json",
};
this.headers = {
Authorization: `Bearer ${this.apiKey}`,
"Content-Type": "application/json",
};
if (version) {
this.headers["x-api-version"] = version.toString();
}
if (version) {
this.headers["x-api-version"] = version.toString();
}
if (orgConfig) {
this.headers["org-config"] = JSON.stringify(orgConfig);
}
if (orgConfig) {
this.headers["org-config"] = JSON.stringify(orgConfig);
}
this.baseUrl = baseUrl || "http://localhost:8080/v1";
}
this.baseUrl = baseUrl || "http://localhost:8080/v1";
}
async get(path: string) {
const response = await fetch(`${this.baseUrl}${path}`, {
headers: this.headers,
});
return response.json();
}
async get(path: string) {
const response = await fetch(`${this.baseUrl}${path}`, {
headers: this.headers,
});
return response.json();
}
async post(path: string, body: any) {
const response = await fetch(`${this.baseUrl}${path}`, {
method: "POST",
headers: this.headers,
body: JSON.stringify(body),
});
async post(path: string, body: any) {
const response = await fetch(`${this.baseUrl}${path}`, {
method: "POST",
headers: this.headers,
body: JSON.stringify(body),
});
if (response.status != 200) {
let error: any;
try {
error = await response.json();
} catch (error) {
throw new AutumnError({
message: "Failed to parse Autumn API error response",
code: ErrCode.InternalError,
});
}
if (response.status != 200) {
let error: any;
try {
error = await response.json();
} catch (error) {
throw new AutumnError({
message: "Failed to parse Autumn API error response",
code: ErrCode.InternalError,
});
}
throw new AutumnError({
message: error.message,
code: error.code,
});
}
throw new AutumnError({
message: error.message,
code: error.code,
});
}
return response.json();
}
return response.json();
}
async delete(
path: string,
{
deleteInStripe = false,
}: {
deleteInStripe?: boolean;
} = {}
) {
const response = await fetch(
`${this.baseUrl}${path}?${deleteInStripe ? "delete_in_stripe=true" : ""}`,
{
method: "DELETE",
headers: this.headers,
}
);
async delete(
path: string,
{
deleteInStripe = false,
}: {
deleteInStripe?: boolean;
} = {},
) {
const response = await fetch(
`${this.baseUrl}${path}?${deleteInStripe ? "delete_in_stripe=true" : ""}`,
{
method: "DELETE",
headers: this.headers,
},
);
if (response.status != 200) {
let error: any;
try {
error = await response.json();
} catch (error) {
throw new AutumnError({
message: "Failed to parse Autumn API error response",
code: ErrCode.InternalError,
});
}
if (response.status != 200) {
let error: any;
try {
error = await response.json();
} catch (error) {
throw new AutumnError({
message: "Failed to parse Autumn API error response",
code: ErrCode.InternalError,
});
}
throw new AutumnError({
message: error.message,
code: error.code,
});
}
throw new AutumnError({
message: error.message,
code: error.code,
});
}
return response.json();
}
return response.json();
}
async createCustomer({
id,
email,
name,
fingerprint,
}: {
id: string;
email: string;
name: string;
fingerprint?: string;
}) {
const data = await this.post("/customers", {
id,
email,
name,
fingerprint,
});
async createCustomer({
id,
email,
name,
fingerprint,
}: {
id: string;
email: string;
name: string;
fingerprint?: string;
}) {
const data = await this.post("/customers", {
id,
email,
name,
fingerprint,
});
return data;
}
return data;
}
async attach(params: AttachBody) {
// const data = await this.post(`/attach`, {
// customer_id: customerId,
// product_id: productId,
// options: toSnakeCase(options),
// });
const data = await this.post(`/attach`, params);
async attach(params: AttachBody) {
// const data = await this.post(`/attach`, {
// customer_id: customerId,
// product_id: productId,
// options: toSnakeCase(options),
// });
const data = await this.post(`/attach`, params);
return data;
}
async checkout(
params: CheckoutParams & { invoice?: boolean; force_checkout?: boolean }
) {
// const data = await this.post(`/attach`, {
// customer_id: customerId,
// product_id: productId,
// options: toSnakeCase(options),
// });
const data = await this.post(`/checkout`, params);
return data;
}
async checkout(
params: CheckoutParams & { invoice?: boolean; force_checkout?: boolean },
) {
// const data = await this.post(`/attach`, {
// customer_id: customerId,
// product_id: productId,
// options: toSnakeCase(options),
// });
const data = await this.post(`/checkout`, params);
return data as CheckoutResult;
}
async transfer(
customerId: string,
params: {
from_entity_id?: string;
to_entity_id: string;
product_id: string;
}
) {
const data = await this.post(`/customers/${customerId}/transfer`, params);
return data as CheckoutResult;
}
async transfer(
customerId: string,
params: {
from_entity_id?: string;
to_entity_id: string;
product_id: string;
},
) {
const data = await this.post(`/customers/${customerId}/transfer`, params);
return data as CheckoutResult;
}
return data as CheckoutResult;
}
async sendEvent({
customerId,
eventName,
properties,
customer_data,
idempotency_key,
}: {
customerId: string;
eventName: string;
properties?: any;
customer_data?: any;
idempotency_key?: string;
}) {
const data = await this.post(`/events`, {
customer_id: customerId,
event_name: eventName,
properties,
customer_data,
idempotency_key,
});
async sendEvent({
customerId,
eventName,
properties,
customer_data,
idempotency_key,
}: {
customerId: string;
eventName: string;
properties?: any;
customer_data?: any;
idempotency_key?: string;
}) {
const data = await this.post(`/events`, {
customer_id: customerId,
event_name: eventName,
properties,
customer_data,
idempotency_key,
});
return data;
}
return data;
}
async entitled({
customerId,
featureId,
quantity,
customer_data,
}: {
customerId: string;
featureId: string;
quantity?: number;
customer_data?: any;
}) {
const data = await this.post(`/entitled`, {
customer_id: customerId,
feature_id: featureId,
quantity,
customer_data,
});
async entitled({
customerId,
featureId,
quantity,
customer_data,
}: {
customerId: string;
featureId: string;
quantity?: number;
customer_data?: any;
}) {
const data = await this.post(`/entitled`, {
customer_id: customerId,
feature_id: featureId,
quantity,
customer_data,
});
return data;
}
return data;
}
customers = {
get: async (
customerId: string,
params?: {
expand?: CusExpand[];
}
): Promise<
Customer & {
invoices: any[];
}
> => {
const queryParams = new URLSearchParams();
const defaultParams = {
expand: [CusExpand.Invoices],
};
customers = {
get: async (
customerId: string,
params?: {
expand?: CusExpand[];
},
): Promise<
Customer & {
invoices: any[];
}
> => {
const queryParams = new URLSearchParams();
const defaultParams = {
expand: [CusExpand.Invoices],
};
const finalParams = { ...defaultParams, ...params };
if (finalParams.expand) {
queryParams.append("expand", finalParams.expand.join(","));
}
const finalParams = { ...defaultParams, ...params };
if (finalParams.expand) {
queryParams.append("expand", finalParams.expand.join(","));
}
const data = await this.get(
`/customers/${customerId}?${queryParams.toString()}`
);
return data;
},
const data = await this.get(
`/customers/${customerId}?${queryParams.toString()}`,
);
return data;
},
create: async (customer: { id: string; email: string; name?: string }) => {
const data = await this.post(`/customers?with_autumn_id=true`, customer);
return data;
},
delete: async (
customerId: string,
{
deleteInStripe = false,
}: {
deleteInStripe?: boolean;
} = {}
) => {
const data = await this.delete(`/customers/${customerId}`, {
deleteInStripe,
});
return data;
},
};
create: async (customer: { id: string; email: string; name?: string }) => {
const data = await this.post(`/customers?with_autumn_id=true`, customer);
return data;
},
delete: async (
customerId: string,
{
deleteInStripe = false,
}: {
deleteInStripe?: boolean;
} = {},
) => {
const data = await this.delete(`/customers/${customerId}`, {
deleteInStripe,
});
return data;
},
};
entities = {
get: async (customerId: string, entityId: string) => {
const data = await this.get(
`/customers/${customerId}/entities/${entityId}?expand=${EntityExpand.Invoices}`
);
return data;
},
entities = {
get: async (customerId: string, entityId: string) => {
const data = await this.get(
`/customers/${customerId}/entities/${entityId}?expand=${EntityExpand.Invoices}`,
);
return data;
},
create: async (
customerId: string,
entity: CreateEntity | CreateEntity[]
) => {
// let entities = Array.isArray(entity) ? entity : [entity];
const data = await this.post(
`/customers/${customerId}/entities?with_autumn_id=true`,
entity
);
create: async (
customerId: string,
entity: CreateEntity | CreateEntity[],
) => {
// let entities = Array.isArray(entity) ? entity : [entity];
const data = await this.post(
`/customers/${customerId}/entities?with_autumn_id=true`,
entity,
);
return data;
},
return data;
},
list: async (customerId: string) => {
const data = await this.get(`/customers/${customerId}/entities`);
return data;
},
list: async (customerId: string) => {
const data = await this.get(`/customers/${customerId}/entities`);
return data;
},
delete: async (customerId: string, entityId: string) => {
const data = await this.delete(
`/customers/${customerId}/entities/${entityId}`
);
return data;
},
};
delete: async (customerId: string, entityId: string) => {
const data = await this.delete(
`/customers/${customerId}/entities/${entityId}`,
);
return data;
},
};
products = {
update: async (productId: string, product: any) => {
// if (product.items && typeof product.items === "object") {
// product.items = Object.values(product.items);
// }
const data = await this.post(`/products/${productId}`, product);
return data;
},
products = {
update: async (productId: string, product: any) => {
// if (product.items && typeof product.items === "object") {
// product.items = Object.values(product.items);
// }
const data = await this.post(`/products/${productId}`, product);
return data;
},
get: async (
productId: string,
{ v1Schema = false }: { v1Schema?: boolean } = {}
) => {
const data = await this.get(
`/products/${productId}?${v1Schema ? "schemaVersion=1" : ""}`
);
return data;
},
get: async (
productId: string,
{ v1Schema = false }: { v1Schema?: boolean } = {},
) => {
const data = await this.get(
`/products/${productId}?${v1Schema ? "schemaVersion=1" : ""}`,
);
return data;
},
create: async (product: any) => {
const data = await this.post(`/products`, product);
return data;
},
create: async (product: any) => {
const data = await this.post(`/products`, product);
return data;
},
delete: async (productId: string) => {
const data = await this.delete(`/products/${productId}`);
return data;
},
};
delete: async (productId: string) => {
const data = await this.delete(`/products/${productId}`);
return data;
},
};
rewards = {
get: async (rewardId: string) => {
const data = await this.get(`/rewards/${rewardId}`);
return data;
},
rewards = {
get: async (rewardId: string) => {
const data = await this.get(`/rewards/${rewardId}`);
return data;
},
create: async (reward: any) => {
const data = await this.post(`/rewards?legacyStripe=true`, reward);
return data;
},
create: async (reward: any) => {
const data = await this.post(`/rewards?legacyStripe=true`, reward);
return data;
},
delete: async (rewardId: string) => {
const data = await this.delete(`/rewards/${rewardId}`);
return data;
},
};
delete: async (rewardId: string) => {
const data = await this.delete(`/rewards/${rewardId}`);
return data;
},
};
rewardPrograms = {
create: async (rewardProgram: CreateRewardProgram) => {
const data = await this.post(`/reward_programs`, rewardProgram);
return data;
},
};
rewardPrograms = {
create: async (rewardProgram: CreateRewardProgram) => {
const data = await this.post(`/reward_programs`, rewardProgram);
return data;
},
};
referrals = {
createCode: async ({
customerId,
referralId,
}: {
customerId: string;
referralId: string;
}) => {
const data = await this.post(`/referrals/code`, {
customer_id: customerId,
program_id: referralId,
});
return data;
},
redeem: async ({
customerId,
code,
}: {
customerId: string;
code: string;
}) => {
const data = await this.post(`/referrals/redeem`, {
customer_id: customerId,
code,
});
return data;
},
};
referrals = {
createCode: async ({
customerId,
referralId,
}: {
customerId: string;
referralId: string;
}) => {
const data = await this.post(`/referrals/code`, {
customer_id: customerId,
program_id: referralId,
});
return data;
},
redeem: async ({
customerId,
code,
}: {
customerId: string;
code: string;
}) => {
const data = await this.post(`/referrals/redeem`, {
customer_id: customerId,
code,
});
return data;
},
};
redemptions = {
get: async (redemptionId: string) => {
const data = await this.get(`/redemptions/${redemptionId}`);
return data;
},
};
redemptions = {
get: async (redemptionId: string) => {
const data = await this.get(`/redemptions/${redemptionId}`);
return data as RewardRedemption;
},
};
events = {
send: async ({
customerId,
featureId,
value,
properties,
}: {
customerId: string;
featureId: string;
value: number;
properties?: any;
}) => {
const data = await this.post(`/events`, {
customer_id: customerId,
feature_id: featureId,
value,
properties,
});
return data;
},
};
events = {
send: async ({
customerId,
featureId,
value,
properties,
}: {
customerId: string;
featureId: string;
value: number;
properties?: any;
}) => {
const data = await this.post(`/events`, {
customer_id: customerId,
feature_id: featureId,
value,
properties,
});
return data;
},
};
stripe = {
connect: async (params: {
secret_key: string;
success_url: string;
default_currency: string;
}) => {
const data = await this.post(`/organization/stripe`, params);
return data;
},
stripe = {
connect: async (params: {
secret_key: string;
success_url: string;
default_currency: string;
}) => {
const data = await this.post(`/organization/stripe`, params);
return data;
},
delete: async () => {
const data = await this.delete(`/organization/stripe`);
return data;
},
};
delete: async () => {
const data = await this.delete(`/organization/stripe`);
return data;
},
};
track = async (params: TrackParams & { timestamp?: number }) => {
const data = await this.post(`/track`, params);
return data;
};
track = async (params: TrackParams & { timestamp?: number }) => {
const data = await this.post(`/track`, params);
return data;
};
usage = async (params: UsageParams) => {
const data = await this.post(`/usage`, params);
return data;
};
usage = async (params: UsageParams) => {
const data = await this.post(`/usage`, params);
return data;
};
check = async (params: CheckParams): Promise<CheckResult> => {
const data = await this.post(`/check`, params);
return data;
};
check = async (params: CheckParams): Promise<CheckResult> => {
const data = await this.post(`/check`, params);
return data;
};
attachPreview = async (params: AttachBody) => {
const data = await this.post(`/attach/preview`, params);
return data;
};
attachPreview = async (params: AttachBody) => {
const data = await this.post(`/attach/preview`, params);
return data;
};
cancel = async (params: CancelParams) => {
const data = await this.post(`/cancel`, params);
return data;
};
cancel = async (params: CancelParams) => {
const data = await this.post(`/cancel`, params);
return data;
};
migrate = async (params: {
from_product_id: string;
to_product_id: string;
from_version: number;
to_version: number;
}) => {
const data = await this.post(`/migrations`, params);
return data;
};
migrate = async (params: {
from_product_id: string;
to_product_id: string;
from_version: number;
to_version: number;
}) => {
const data = await this.post(`/migrations`, params);
return data;
};
initStripe = async () => {
await this.post(`/products/all/init_stripe`, {});
};
initStripe = async () => {
await this.post(`/products/all/init_stripe`, {});
};
}

View File

@@ -61,36 +61,9 @@ autumnWebhookRouter.post(
switch (type) {
case WebhookEventType.CustomerProductsUpdated:
console.log(`--------------------------------`);
// console.log(`Received customer.products.updated webhook`);
// console.log(JSON.stringify(data, null, 2));
console.log(
`Customer:`,
data?.customer.id,
`Products:`,
data?.customer.products.map((p: any) => ({
id: p.id,
entity_id: p.entity_id,
status: p.status,
quantity: p.quantity,
}))
`Type: ${type}, Scenario: ${data?.scenario}, Product: ${data?.updated_product?.id}`
);
if (data?.entity) {
console.log(
`Entity: ${data.entity.id}, Products:`,
data.entity.products.map((p: any) => ({
id: p.id,
status: p.status,
quantity: p.quantity,
}))
);
}
console.log(
`Update product ID: ${data?.updated_product?.id}, Scenario: ${data?.scenario}`
);
console.log(`--------------------------------`);
break;
case WebhookEventType.CustomerThresholdReached:
console.log(`Type: ${type}`);

View File

@@ -0,0 +1,130 @@
// import Stripe from "stripe";
// /**
// * Extends an existing coupon's duration by replacing it with a new coupon that has
// * the remaining duration + additional months. This avoids concurrent stacking issues.
// */
// export const extendCouponDuration = async ({
// stripeCli,
// sub,
// existingCouponId,
// additionalMonths,
// logger,
// }: {
// stripeCli: Stripe;
// sub: Stripe.Subscription;
// existingCouponId: string;
// additionalMonths: number;
// logger: any;
// }): Promise<{
// success: boolean;
// newCouponId?: string;
// error?: string;
// }> => {
// try {
// logger.info(
// `Extending coupon ${existingCouponId} by ${additionalMonths} months`
// );
// // Get current subscription and coupon details
// const existingCoupon = await stripeCli.coupons.retrieve(existingCouponId);
// const currentDiscounts = (sub.discounts as Stripe.Discount[]) || [];
// const existingDiscount = currentDiscounts.find((d: any) =>
// d.coupon?.id.startsWith(existingCouponId)
// );
// if (!existingDiscount) {
// return {
// success: false,
// error: "Original coupon not found on subscription",
// };
// }
// // Calculate remaining months using the discount's actual start and end times
// const originalDurationMonths = existingCoupon.duration_in_months || 0;
// // Use discount start and end times for accurate calculation
// const discountStart = new Date(existingDiscount.start * 1000);
// const discountEnd = existingDiscount.end
// ? new Date(existingDiscount.end * 1000)
// : null;
// const now = new Date();
// let remainingMonths: number;
// if (discountEnd) {
// // Calculate remaining time in months
// const remainingTimeMs = Math.max(
// 0,
// discountEnd.getTime() - now.getTime()
// );
// remainingMonths = Math.ceil(remainingTimeMs / (30 * 24 * 60 * 60 * 1000));
// } else {
// // If no end date (shouldn't happen for repeating coupons), fall back to original duration
// remainingMonths = originalDurationMonths;
// }
// const totalNewDurationMonths = remainingMonths + additionalMonths;
// logger.info(
// `Discount period: ${discountStart.toISOString()} to ${discountEnd?.toISOString() || "forever"}`
// );
// logger.info(
// `Original: ${originalDurationMonths}m, Remaining: ${remainingMonths}m, Adding: ${additionalMonths}m, Total: ${totalNewDurationMonths}m`
// );
// // Create a new coupon with the extended duration
// const extendedCouponId = `${existingCouponId}_${Date.now()}`;
// const couponCreateParams: Stripe.CouponCreateParams = {
// id: extendedCouponId,
// duration: "repeating",
// duration_in_months: totalNewDurationMonths,
// name: `Extended ${existingCoupon.name || "Coupon"}`,
// // metadata: {
// // original_coupon_id: existingCouponId,
// // original_duration: originalDurationMonths.toString(),
// // remaining_months: remainingMonths.toString(),
// // additional_months: additionalMonths.toString(),
// // total_duration: totalNewDurationMonths.toString(),
// // extended_at: Date.now().toString(),
// // },
// };
// // Copy discount value from the existing coupon
// if (existingCoupon.percent_off) {
// couponCreateParams.percent_off = existingCoupon.percent_off;
// } else if (existingCoupon.amount_off) {
// couponCreateParams.amount_off = existingCoupon.amount_off;
// if (existingCoupon.currency) {
// couponCreateParams.currency = existingCoupon.currency;
// }
// }
// // Copy applies_to if it exists
// if (existingCoupon.applies_to) {
// couponCreateParams.applies_to = existingCoupon.applies_to;
// }
// const extendedCoupon = await stripeCli.coupons.create(couponCreateParams);
// // Replace the existing coupon with the extended one
// const otherDiscounts = currentDiscounts
// .filter((d: any) => d.coupon?.id !== existingCouponId)
// .map((d: Stripe.Discount) => ({ discount: d.id }));
// await stripeCli.subscriptions.update(sub.id, {
// discounts: [...otherDiscounts, { coupon: extendedCoupon.id }],
// });
// logger.info(
// `Successfully extended coupon duration to ${totalNewDurationMonths} months. New coupon ID: ${extendedCouponId}`
// );
// await stripeCli.coupons.del(existingCouponId);
// return { success: true, newCouponId: extendedCouponId };
// } catch (error: any) {
// logger.error(`Failed to extend coupon duration: ${error.message}`, error);
// return { success: false, error: error.message };
// }
// };

View File

@@ -1,23 +1,48 @@
import RecaseError from "@/utils/errorUtils.js";
import {
Reward,
type AppEnv,
CouponDurationType,
ErrCode,
Organization,
Price,
type FixedPriceConfig,
type Organization,
type Price,
PriceType,
Product,
UsagePriceConfig,
type Product,
type Reward,
RewardType,
AppEnv,
type UsagePriceConfig,
} from "@autumn/shared";
import { Stripe } from "stripe";
import { pricesOnlyOneOff } from "@/internal/products/prices/priceUtils.js";
import RecaseError from "@/utils/errorUtils.js";
import { createStripeCli } from "../utils.js";
const couponToStripeDuration = (coupon: Reward) => {
let discountConfig = coupon.discount_config;
const couponToStripeDuration = ({
coupon,
isOneOffProduct = false,
}: {
coupon: Reward;
isOneOffProduct: boolean;
}) => {
if (coupon.type === RewardType.FreeProduct) {
// For one-off products, the coupon should apply once, not repeat
if (isOneOffProduct) {
return {
duration: "once",
};
}
console.log("--------------------------------");
console.log("rewardName", coupon.name);
console.log("coupon.free_product_config", coupon.free_product_config);
console.log("isOneOffProduct", isOneOffProduct);
console.log("--------------------------------");
return {
duration: "repeating",
duration_in_months: coupon.free_product_config?.duration_value,
};
}
const discountConfig = coupon.discount_config;
if (
coupon.type == RewardType.InvoiceCredits &&
coupon.type === RewardType.InvoiceCredits &&
coupon.discount_config?.duration_type === CouponDurationType.Forever
) {
return {
@@ -25,6 +50,13 @@ const couponToStripeDuration = (coupon: Reward) => {
};
}
// For one-off products, always use "once" duration regardless of config
if (isOneOffProduct) {
return {
duration: "once",
};
}
switch (discountConfig!.duration_type) {
case CouponDurationType.Forever:
return {
@@ -45,11 +77,28 @@ const couponToStripeDuration = (coupon: Reward) => {
const couponToStripeValue = ({
reward,
org,
prices,
}: {
reward: Reward;
org: Organization;
prices?: (Price & { product: Product })[];
}) => {
let discountConfig = reward.discount_config;
if (reward.type === RewardType.FreeProduct) {
const amountOff = Math.round(
prices?.reduce(
(acc, price) => acc + (price.config as FixedPriceConfig).amount,
0
) || 0
);
console.log("amountOff in couponToStripeValue", amountOff);
return {
amount_off: Math.round(amountOff * 100),
currency: org.default_currency || "usd",
};
}
const discountConfig = reward.discount_config;
if (reward.type === RewardType.PercentageDiscount) {
return {
percent_off: discountConfig!.discount_value,
@@ -80,7 +129,7 @@ export const createStripeCoupon = async ({
logger: any;
legacyVersion?: boolean;
}) => {
let discountConfig = reward.discount_config;
const discountConfig = reward.discount_config;
const stripeCli = createStripeCli({
org,
@@ -90,9 +139,9 @@ export const createStripeCoupon = async ({
try {
await stripeCli.coupons.del(reward.id);
} catch (error) {}
} catch (_) {}
let stripeProdIds = prices.map((price) => {
const stripeProdIds = prices.map((price) => {
if (price.config!.type === PriceType.Fixed) {
return price.product.processor?.id;
} else {
@@ -116,26 +165,32 @@ export const createStripeCoupon = async ({
promoCode.code
);
throw new RecaseError({
message: `Promo code ${promoCode.code} already exists in Stripe`,
message: `Promo code ${promoCode.code} (${stripePromoCode.id}) already exists in Stripe`,
code: ErrCode.PromoCodeAlreadyExistsInStripe,
});
} catch (error) {}
} catch (_) {}
}
const stripeCoupon = await stripeCli.coupons.create({
// id: reward.internal_id,
id: reward.id,
...(couponToStripeDuration(reward) as any),
...(couponToStripeValue({ reward, org }) as any),
...(couponToStripeDuration({
coupon: reward,
isOneOffProduct: pricesOnlyOneOff(prices),
}) as any),
...(couponToStripeValue({ reward, org, prices }) as any),
name: reward.name,
metadata: {
autumn_internal_id: reward.internal_id,
},
applies_to: !discountConfig!.apply_to_all
? {
products: stripeProdIds,
}
: undefined,
applies_to:
reward.type === RewardType.FreeProduct
? undefined
: !discountConfig!.apply_to_all
? {
products: stripeProdIds,
}
: undefined,
});
// Create promo codes

View File

@@ -1,332 +1,338 @@
import { AppEnv, Customer, Organization, ProcessorType } from "@autumn/shared";
import { Stripe } from "stripe";
import { createStripeCli } from "./utils.js";
import RecaseError from "@/utils/errorUtils.js";
import { ErrCode } from "@autumn/shared";
import {
type AppEnv,
type Customer,
ErrCode,
type Organization,
ProcessorType,
} from "@autumn/shared";
import { StatusCodes } from "http-status-codes";
import type { Stripe } from "stripe";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import { CusService } from "@/internal/customers/CusService.js";
import { DrizzleCli } from "@/db/initDrizzle.js";
import RecaseError from "@/utils/errorUtils.js";
import { createStripeCli } from "./utils.js";
export const getStripeCus = async ({
stripeCli,
stripeId,
stripeCli,
stripeId,
}: {
stripeCli: Stripe;
stripeId: string;
stripeCli: Stripe;
stripeId: string;
}) => {
try {
const stripeCus = await stripeCli.customers.retrieve(stripeId);
return stripeCus as Stripe.Customer;
} catch (error) {
return undefined;
}
try {
const stripeCus = await stripeCli.customers.retrieve(stripeId);
return stripeCus as Stripe.Customer;
} catch (error) {
return undefined;
}
};
export const createStripeCusIfNotExists = async ({
db,
org,
env,
customer,
logger,
db,
org,
env,
customer,
logger,
}: {
db: DrizzleCli;
org: Organization;
env: AppEnv;
customer: Customer;
logger: any;
db: DrizzleCli;
org: Organization;
env: AppEnv;
customer: Customer;
logger: any;
}) => {
let createNew = false;
const stripeCli = createStripeCli({ org, env });
if (!customer.processor || !customer.processor.id) {
createNew = true;
} else {
try {
let stripeCus = await stripeCli.customers.retrieve(
customer.processor.id,
{
expand: ["test_clock", "invoice_settings.default_payment_method"],
}
);
if (!stripeCus.deleted) {
return stripeCus as Stripe.Customer;
} else {
createNew = true;
}
} catch (error) {
createNew = true;
}
}
let createNew = false;
const stripeCli = createStripeCli({ org, env });
if (!customer.processor || !customer.processor.id) {
createNew = true;
} else {
try {
const stripeCus = await stripeCli.customers.retrieve(
customer.processor.id,
{
expand: ["test_clock", "invoice_settings.default_payment_method"],
},
);
if (!stripeCus.deleted) {
return stripeCus as Stripe.Customer;
} else {
createNew = true;
}
} catch (error) {
createNew = true;
}
}
if (createNew) {
logger.info(`Creating new stripe customer for ${customer.id}`);
const stripeCustomer = await createStripeCustomer({
org,
env,
customer,
});
if (createNew) {
logger.info(`Creating new stripe customer for ${customer.id}`);
const stripeCustomer = await createStripeCustomer({
org,
env,
customer,
});
await CusService.update({
db,
internalCusId: customer.internal_id,
update: {
processor: {
id: stripeCustomer.id,
type: ProcessorType.Stripe,
},
},
});
await CusService.update({
db,
internalCusId: customer.internal_id,
update: {
processor: {
id: stripeCustomer.id,
type: ProcessorType.Stripe,
},
},
});
customer.processor = {
id: stripeCustomer.id,
type: ProcessorType.Stripe,
};
customer.processor = {
id: stripeCustomer.id,
type: ProcessorType.Stripe,
};
return stripeCustomer;
}
return stripeCustomer;
}
};
export const createStripeCustomer = async ({
org,
env,
customer,
testClockId,
org,
env,
customer,
testClockId,
}: {
org: Organization;
env: AppEnv;
customer: Customer;
testClockId?: string;
org: Organization;
env: AppEnv;
customer: Customer;
testClockId?: string;
}) => {
const stripeCli = createStripeCli({ org, env });
const stripeCli = createStripeCli({ org, env });
try {
const stripeCustomer = await stripeCli.customers.create({
name: customer.name || undefined,
email: customer.email || undefined,
metadata: {
autumn_id: customer.id || null,
autumn_internal_id: customer.internal_id,
},
test_clock: testClockId,
});
try {
const stripeCustomer = await stripeCli.customers.create({
name: customer.name || undefined,
email: customer.email || undefined,
metadata: {
autumn_id: customer.id || null,
autumn_internal_id: customer.internal_id,
},
test_clock: testClockId,
});
return stripeCustomer;
} catch (error: any) {
throw new RecaseError({
message: `Error creating customer in Stripe. ${error.message}`,
code: ErrCode.StripeCreateCustomerFailed,
statusCode: StatusCodes.INTERNAL_SERVER_ERROR,
});
}
return stripeCustomer;
} catch (error: any) {
throw new RecaseError({
message: `Error creating customer in Stripe. ${error.message}`,
code: ErrCode.StripeCreateCustomerFailed,
statusCode: StatusCodes.INTERNAL_SERVER_ERROR,
});
}
};
export const deleteStripeCustomer = async ({
org,
env,
stripeId,
org,
env,
stripeId,
}: {
org: Organization;
env: AppEnv;
stripeId: string;
org: Organization;
env: AppEnv;
stripeId: string;
}) => {
const stripeCli = createStripeCli({ org, env });
const stripeCli = createStripeCli({ org, env });
const stripeCustomer = await stripeCli.customers.del(stripeId);
const stripeCustomer = await stripeCli.customers.del(stripeId);
return stripeCustomer;
return stripeCustomer;
};
export const listCusPaymentMethods = async ({
stripeCli,
stripeId,
stripeCli,
stripeId,
}: {
stripeCli: Stripe;
stripeId: string;
stripeCli: Stripe;
stripeId: string;
}) => {
let res = await stripeCli.paymentMethods.list({
customer: stripeId,
});
const res = await stripeCli.paymentMethods.list({
customer: stripeId,
});
const paymentMethods = res.data;
paymentMethods.sort((a, b) => b.created - a.created);
const paymentMethods = res.data;
paymentMethods.sort((a, b) => b.created - a.created);
return paymentMethods;
return paymentMethods;
};
export const getCusPaymentMethod = async ({
stripeCli,
stripeId,
errorIfNone = false,
stripeCli,
stripeId,
errorIfNone = false,
}: {
stripeCli: Stripe;
stripeId?: string;
errorIfNone?: boolean;
stripeCli: Stripe;
stripeId?: string;
errorIfNone?: boolean;
}) => {
if (!stripeId) {
return null;
}
if (!stripeId) {
return null;
}
const stripeCustomer = (await stripeCli.customers.retrieve(
stripeId
)) as Stripe.Customer;
const stripeCustomer = (await stripeCli.customers.retrieve(
stripeId,
)) as Stripe.Customer;
let paymentMethodId = stripeCustomer.invoice_settings?.default_payment_method;
const paymentMethodId =
stripeCustomer.invoice_settings?.default_payment_method;
if (!paymentMethodId) {
let res = await stripeCli.paymentMethods.list({
customer: stripeId,
});
if (!paymentMethodId) {
const res = await stripeCli.paymentMethods.list({
customer: stripeId,
});
const paymentMethods = res.data;
paymentMethods.sort((a, b) => b.created - a.created);
const paymentMethods = res.data;
paymentMethods.sort((a, b) => b.created - a.created);
if (res.data.length === 0) {
if (errorIfNone) {
throw new RecaseError({
code: ErrCode.StripeGetPaymentMethodFailed,
message: `No payment method found for customer ${stripeId}`,
statusCode: 500,
});
}
return null;
}
if (res.data.length === 0) {
if (errorIfNone) {
throw new RecaseError({
code: ErrCode.StripeGetPaymentMethodFailed,
message: `No payment method found for customer ${stripeId}`,
statusCode: 500,
});
}
return null;
}
return paymentMethods[0];
} else {
const paymentMethod = await stripeCli.paymentMethods.retrieve(
paymentMethodId as string
);
return paymentMethod;
}
return paymentMethods[0];
} else {
const paymentMethod = await stripeCli.paymentMethods.retrieve(
paymentMethodId as string,
);
return paymentMethod;
}
};
// 2. Create a payment method and attach to customer
export const attachPmToCus = async ({
db,
customer,
org,
env,
willFail = false,
testClockId,
db,
customer,
org,
env,
willFail = false,
testClockId,
}: {
db: DrizzleCli;
customer: Customer;
org: Organization;
env: AppEnv;
willFail?: boolean;
testClockId?: string;
db: DrizzleCli;
customer: Customer;
org: Organization;
env: AppEnv;
willFail?: boolean;
testClockId?: string;
}) => {
// 1. Create stripe customer if not exists
// 1. Create stripe customer if not exists
let stripeCusId = customer.processor?.id;
if (!stripeCusId) {
const stripeCustomer = await createStripeCustomer({
org,
env,
customer,
testClockId,
});
let stripeCusId = customer.processor?.id;
if (!stripeCusId) {
const stripeCustomer = await createStripeCustomer({
org,
env,
customer,
testClockId,
});
await CusService.update({
db,
internalCusId: customer.internal_id,
update: {
processor: {
id: stripeCustomer.id,
type: ProcessorType.Stripe,
},
},
});
await CusService.update({
db,
internalCusId: customer.internal_id,
update: {
processor: {
id: stripeCustomer.id,
type: ProcessorType.Stripe,
},
},
});
stripeCusId = stripeCustomer.id;
customer.processor = {
id: stripeCustomer.id,
type: "stripe",
};
}
stripeCusId = stripeCustomer.id;
customer.processor = {
id: stripeCustomer.id,
type: "stripe",
};
}
const stripeCli = createStripeCli({ org, env });
const stripeCli = createStripeCli({ org, env });
try {
let token = willFail ? "tok_chargeCustomerFail" : "tok_visa";
const pm = await stripeCli.paymentMethods.create({
type: "card",
card: {
token,
},
});
await stripeCli.paymentMethods.attach(pm.id, {
customer: stripeCusId,
});
try {
const token = willFail ? "tok_chargeCustomerFail" : "tok_visa";
const pm = await stripeCli.paymentMethods.create({
type: "card",
card: {
token,
},
});
await stripeCli.paymentMethods.attach(pm.id, {
customer: stripeCusId,
});
await stripeCli.customers.update(stripeCusId, {
invoice_settings: {
default_payment_method: pm.id,
},
});
// console.log(" - Payment method attached");
} catch (error) {
console.log(" - Error attaching payment method", error);
}
await stripeCli.customers.update(stripeCusId, {
invoice_settings: {
default_payment_method: pm.id,
},
});
// console.log(" - Payment method attached");
} catch (error) {
console.log(" - Error attaching payment method", error);
}
};
export const attachFailedPaymentMethod = async ({
stripeCli,
customer,
stripeCli,
customer,
}: {
stripeCli: Stripe;
customer: Customer;
stripeCli: Stripe;
customer: Customer;
}) => {
// Delete existing payment method
const paymentMethods = await stripeCli.paymentMethods.list({
customer: customer.processor?.id,
});
for (const pm of paymentMethods.data) {
await stripeCli.paymentMethods.detach(pm.id);
}
// Delete existing payment method
const paymentMethods = await stripeCli.paymentMethods.list({
customer: customer.processor?.id,
});
for (const pm of paymentMethods.data) {
await stripeCli.paymentMethods.detach(pm.id);
}
const pm = await stripeCli.paymentMethods.create({
type: "card",
card: {
token: "tok_chargeCustomerFail",
},
});
await stripeCli.paymentMethods.attach(pm.id, {
customer: customer.processor?.id,
});
const pm = await stripeCli.paymentMethods.create({
type: "card",
card: {
token: "tok_chargeCustomerFail",
},
});
await stripeCli.paymentMethods.attach(pm.id, {
customer: customer.processor?.id,
});
};
export const deleteAllStripeCustomers = async ({
org,
env,
org,
env,
}: {
org: Organization;
env: AppEnv;
org: Organization;
env: AppEnv;
}) => {
const stripeCli = createStripeCli({ org, env });
const stripeCli = createStripeCli({ org, env });
const stripeCustomers = await stripeCli.customers.list({
limit: 100,
});
const stripeCustomers = await stripeCli.customers.list({
limit: 100,
});
if (stripeCustomers.data.length === 0) {
return;
}
if (stripeCustomers.data.length === 0) {
return;
}
let firstCustomer = stripeCustomers.data[0];
if (firstCustomer.livemode) {
throw new RecaseError({
message: "Cannot delete livemode customers",
code: ErrCode.StripeDeleteCustomerFailed,
statusCode: StatusCodes.INTERNAL_SERVER_ERROR,
});
}
const firstCustomer = stripeCustomers.data[0];
if (firstCustomer.livemode) {
throw new RecaseError({
message: "Cannot delete livemode customers",
code: ErrCode.StripeDeleteCustomerFailed,
statusCode: StatusCodes.INTERNAL_SERVER_ERROR,
});
}
let batchSize = 10;
for (let i = 0; i < stripeCustomers.data.length; i += batchSize) {
let batch = stripeCustomers.data.slice(i, i + batchSize);
await Promise.all(batch.map((c) => stripeCli.customers.del(c.id)));
console.log(
`Deleted ${i + batch.length}/${stripeCustomers.data.length} customers`
);
}
const batchSize = 10;
for (let i = 0; i < stripeCustomers.data.length; i += batchSize) {
const batch = stripeCustomers.data.slice(i, i + batchSize);
await Promise.all(batch.map((c) => stripeCli.customers.del(c.id)));
console.log(
`Deleted ${i + batch.length}/${stripeCustomers.data.length} customers`,
);
}
};

View File

@@ -9,7 +9,11 @@ import { handleCheckoutSessionCompleted } from "./webhookHandlers/handleCheckout
import { handleSubscriptionUpdated } from "./webhookHandlers/handleSubUpdated.js";
import { handleSubDeleted } from "./webhookHandlers/handleSubDeleted.js";
import { handleSubCreated } from "./webhookHandlers/handleSubCreated.js";
import { getStripeWebhookSecret } from "@/internal/orgs/orgUtils.js";
import {
getStripeWebhookSecret,
isStripeConnected,
unsetOrgStripeKeys,
} from "@/internal/orgs/orgUtils.js";
import { handleInvoicePaid } from "./webhookHandlers/handleInvoicePaid.js";
import { handleRequestError } from "@/utils/errorUtils.js";
import { handleInvoiceCreated } from "./webhookHandlers/handleInvoiceCreated/handleInvoiceCreated.js";
@@ -22,6 +26,7 @@ import { deleteCusCache } from "@/internal/customers/cusCache/updateCachedCus.js
import { CusService } from "@/internal/customers/CusService.js";
import { DrizzleCli } from "@/db/initDrizzle.js";
import { handleInvoiceUpdated } from "./webhookHandlers/handleInvoiceUpdated.js";
import { disconnectStripe } from "@/internal/orgs/handlers/handleDeleteStripe.js";
export const stripeWebhookRouter: Router = express.Router();
@@ -66,9 +71,11 @@ stripeWebhookRouter.post(
request.env = env;
org = data.org;
if (!org.stripe_config) {
console.log(`Org ${orgId} does not have a stripe config`);
response.status(200).send(`Org ${orgId} does not have a stripe config`);
if (!isStripeConnected({ org, env })) {
console.log(`Org ${orgId} and env ${env} is not connected to stripe`);
response
.status(200)
.send(`Org ${orgId} and env ${env} is not connected to stripe`);
return;
}
@@ -115,15 +122,6 @@ stripeWebhookRouter.post(
let logger = request.logtail;
logStripeWebhook({ req: request, event });
// const logger = createLogtailWithContext({
// action: LoggerAction.StripeWebhook,
// event_type: event.type,
// data: event.data,
// org_id: orgId,
// org_slug: org.slug,
// env,
// });
try {
const stripeCli = createStripeCli({ org, env });
switch (event.type) {
@@ -244,6 +242,18 @@ stripeWebhookRouter.post(
response.status(200).json({ message: "ok" });
return;
}
if (error.message.includes("Expired API Key provided")) {
// Disconnect Stripe
await unsetOrgStripeKeys({
db,
org,
env,
});
response.status(200).json({ message: "ok" });
return;
}
}
handleRequestError({

View File

@@ -1,29 +1,32 @@
import { Stripe } from "stripe";
import { createFullCusProduct } from "@/internal/customers/add-product/createFullCusProduct.js";
import { CusProductService } from "@/internal/customers/cusProducts/CusProductService.js";
import { getMetadataFromCheckoutSession } from "@/internal/metadata/metadataUtils.js";
import {
AppEnv,
type AppEnv,
AttachScenario,
CusProductStatus,
Organization,
notNullish,
type Organization,
} from "@autumn/shared";
import { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js";
import { createStripeCli } from "../utils.js";
import type { Stripe } from "stripe";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import { createFullCusProduct } from "@/internal/customers/add-product/createFullCusProduct.js";
import { CusService } from "@/internal/customers/CusService.js";
import type { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js";
import { CusProductService } from "@/internal/customers/cusProducts/CusProductService.js";
import { insertInvoiceFromAttach } from "@/internal/invoices/invoiceUtils.js";
import { getMetadataFromCheckoutSession } from "@/internal/metadata/metadataUtils.js";
import { attachToInsertParams } from "@/internal/products/productUtils.js";
import { JobName } from "@/queue/JobName.js";
import { addTaskToQueue } from "@/queue/queueUtils.js";
import { insertInvoiceFromAttach } from "@/internal/invoices/invoiceUtils.js";
import { DrizzleCli } from "@/db/initDrizzle.js";
import { ExtendedRequest } from "@/utils/models/Request.js";
import type { ExtendedRequest } from "@/utils/models/Request.js";
import { createStripeCli } from "../utils.js";
import { handleCheckoutSub } from "./handleCheckoutCompleted/handleCheckoutSub.js";
import { handleRemainingSets } from "./handleCheckoutCompleted/handleRemainingSets.js";
import { handleSetupCheckout } from "./handleCheckoutCompleted/handleSetupCheckout.js";
import { getOptionsFromCheckoutSession } from "./handleCheckoutCompleted/getOptionsFromCheckout.js";
import { getEarliestPeriodEnd } from "../stripeSubUtils/convertSubUtils.js";
import { notNullish } from "@/utils/genUtils.js";
import { CusService } from "@/internal/customers/CusService.js";
import { handleSetupCheckout } from "./handleCheckoutCompleted/handleSetupCheckout.js";
export const handleCheckoutSessionCompleted = async ({
req,
@@ -125,7 +128,6 @@ export const handleCheckoutSessionCompleted = async ({
const anchorToUnix = checkoutSub
? getEarliestPeriodEnd({ sub: checkoutSub! }) * 1000
: undefined;
if (attachParams.productsList) {
console.log("Inserting products list");
for (const productOptions of attachParams.productsList) {
@@ -188,6 +190,8 @@ export const handleCheckoutSessionCompleted = async ({
console.log("✅ checkout.completed: successfully inserted invoices");
for (const product of attachParams.products) {
console.log("Adding task to queue for trigger checkout reward");
console.log("Adding task to queue for trigger checkout reward");
await addTaskToQueue({
jobName: JobName.TriggerCheckoutReward,
payload: {
@@ -225,72 +229,5 @@ export const handleCheckoutSessionCompleted = async ({
});
}
// if (
// !attachParams.customer.name &&
// notNullish(checkoutSession.customer_details?.name)
// ) {
// updates.push(
// CusService.update({
// db,
// internalCusId: attachParams.customer.internal_id,
// update: {
// name: checkoutSession.customer_details?.name,
// },
// })
// );
// }
// if (
// !attachParams.customer.email &&
// notNullish(checkoutSession.customer_details?.email)
// ) {
// updates.push(
// CusService.update({
// db,
// internalCusId: attachParams.customer.internal_id,
// update: {
// email: checkoutSession.customer_details?.email,
// },
// })
// );
// }
// // Let it fail silently if any of the updates fail.
// if (updates.length > 0) await Promise.allSettled(updates);
return;
};
// for (const invoiceId of invoiceIds) {
// try {
// const invoice = await getStripeExpandedInvoice({
// stripeCli,
// stripeInvoiceId: invoiceId,
// });
// let invoiceItems = await getInvoiceItems({
// stripeInvoice: invoice,
// prices: attachParams.prices,
// logger,
// });
// await InvoiceService.createInvoiceFromStripe({
// db,
// org,
// stripeInvoice: invoice,
// internalCustomerId: attachParams.customer.internal_id,
// productIds: products.map((p) => p.id),
// internalProductIds: products.map((p) => p.internal_id),
// internalEntityId: attachParams.internalEntityId,
// items: invoiceItems,
// });
// console.log(" ✅ checkout.completed: successfully created invoice");
// } catch (error) {
// console.error("checkout.completed: error creating invoice", error);
// }
// }
// subscriptionId: !isOneOff
// ? (checkoutSession.subscription as string)
// : undefined,

View File

@@ -1,107 +1,170 @@
import type Stripe from "stripe";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import { CusService } from "@/internal/customers/CusService.js";
import { RewardRedemptionService } from "@/internal/rewards/RewardRedemptionService.js";
import { createStripeCli } from "../utils.js";
import Stripe from "stripe";
import { notNullish, timeout } from "@/utils/genUtils.js";
import { DrizzleCli } from "@/db/initDrizzle.js";
import { RewardService } from "@/internal/rewards/RewardService.js";
import { notNullish } from "@/utils/genUtils.js";
import { createStripeCli } from "../utils.js";
export async function handleCusDiscountDeleted({
db,
org,
discount,
env,
logger,
res,
db,
org,
discount,
env,
logger,
res,
}: {
db: DrizzleCli;
org: any;
discount: any;
env: any;
logger: any;
res: any;
db: DrizzleCli;
org: any;
discount: any;
env: any;
logger: any;
res: any;
}) {
let customer = await CusService.getByStripeId({
db,
stripeId: discount.customer,
});
const customer = await CusService.getByStripeId({
db,
stripeId: discount.customer,
});
if (!customer) {
logger.warn(`discount.deleted: customer ${discount.customer} not found`);
return;
}
if (!customer) {
logger.warn(`discount.deleted: customer ${discount.customer} not found`);
return;
}
if (customer.env !== env || customer.org_id !== org.id) {
logger.info(`discount.deleted: env or org mismatch, skipping`);
return;
}
if (customer.env !== env || customer.org_id !== org.id) {
logger.info(
`discount.deleted: env or org mismatch, skipping, ${customer.env} !== ${env} || ${customer.org_id} !== ${org.id}`,
);
return;
}
// Check if any redemptions available, and apply to customer if so
let redemptions = await RewardRedemptionService.getUnappliedRedemptions({
db,
internalCustomerId: customer.internal_id,
});
// Check if any redemptions available, and apply to customer if so
const redemptions = await RewardRedemptionService.getUnappliedRedemptions({
db,
internalCustomerId: customer.internal_id,
});
logger.info(`discount.deleted: found ${redemptions.length} redemptions`);
logger.info(
`discount.deleted:, discount ID: ${discount.id}, found ${redemptions.length} redemptions`,
);
if (redemptions.length == 0) {
return;
}
if (redemptions.length == 0) return;
let redemption = redemptions[0];
const paidProductRedemption = redemptions.find(
(r) =>
r.reward_program.reward.id ===
(typeof discount.coupon == "string"
? discount.coupon
: discount.coupon.id),
);
// Apply redemption to customer
let stripeCli = createStripeCli({
org,
env,
});
if (discount.subscription) {
logger.info(
`Discount is a subscription, paidProductRedemption: ${paidProductRedemption?.id}`,
);
let stripeCus = (await stripeCli.customers.retrieve(
discount.customer
)) as Stripe.Customer;
if (!paidProductRedemption) return;
if (stripeCus && notNullish(stripeCus.discount)) {
logger.info(
`discount.deleted: stripe customer ${discount.customer} already has a discount`
);
return;
}
// Re-apply coupon and mark applied / redeemer applied to true
const stripeCli = createStripeCli({
org,
env,
});
const reward = await RewardService.get({
db,
orgId: org.id,
env,
idOrInternalId: redemption.reward_program.internal_reward_id!,
});
// Mark reward redemption as applied / redeemer applied to true
if (!reward) {
logger.warn(
`discount.deleted: reward ${redemption.reward_program.internal_id} not found`
);
return;
}
const sub = await stripeCli.subscriptions.retrieve(discount.subscription);
const legacyStripe = createStripeCli({
org,
env,
legacyVersion: true,
});
// can't really test because it modifies subscription affected by test clock...
try {
await stripeCli.subscriptions.update(discount.subscription, {
discounts: [
...(sub.discounts as string[]).map((d: string) => ({
discount: d,
})),
{
coupon: paidProductRedemption.reward_program.reward.id as string,
},
],
});
} catch (error: any) {
logger.error(
`Failed to update subscription ${discount.subscription} with paid product coupon, error: ${error.message}`,
);
throw error;
}
await legacyStripe.customers.update(discount.customer, {
// @ts-ignore
coupon: reward.internal_id,
});
// Mark reward redemption as applied / redeemer applied to true
const isReferrer =
paidProductRedemption.referral_code.internal_customer_id ===
customer.internal_id;
await RewardRedemptionService.update({
db,
id: redemption.id,
updates: {
applied: true,
},
});
await RewardRedemptionService.update({
db,
id: paidProductRedemption.id,
updates: {
applied: isReferrer ? true : undefined,
redeemer_applied: isReferrer ? undefined : true,
},
});
logger.info(
`discount.deleted: applied reward ${reward.name} on customer ${customer.name} (${customer.id})`
);
logger.info(`Redemption ID: ${redemption.id}`);
return;
}
const redemption = redemptions[0];
// Apply redemption to customer
const stripeCli = createStripeCli({
org,
env,
});
const stripeCus = (await stripeCli.customers.retrieve(
discount.customer,
)) as Stripe.Customer;
if (stripeCus && notNullish(stripeCus.discount)) {
logger.info(
`discount.deleted: stripe customer ${discount.customer} already has a discount`,
);
return;
}
const reward = await RewardService.get({
db,
orgId: org.id,
env,
idOrInternalId: redemption.reward_program.internal_reward_id!,
});
if (!reward) {
logger.warn(
`discount.deleted: reward ${redemption.reward_program.internal_id} not found`,
);
return;
}
const legacyStripe = createStripeCli({
org,
env,
legacyVersion: true,
});
await legacyStripe.customers.update(discount.customer, {
// @ts-expect-error
coupon: reward.id,
});
await RewardRedemptionService.update({
db,
id: redemption.id,
updates: {
applied: true,
},
});
logger.info(
`discount.deleted: applied reward ${reward.name} on customer ${customer.name} (${customer.id})`,
);
logger.info(`Redemption ID: ${redemption.id}`);
}

View File

@@ -1,14 +1,18 @@
import Stripe from "stripe";
import { CusProductService } from "@/internal/customers/cusProducts/CusProductService.js";
import {
import type {
AppEnv,
FullCusProduct,
FullCustomerPrice,
InvoiceStatus,
Organization,
} from "@autumn/shared";
import { createStripeCli } from "../utils.js";
import type Stripe from "stripe";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import { handleInvoiceCheckoutPaid } from "@/internal/customers/attach/attachFunctions/invoiceCheckoutPaid/handleInvoiceCheckoutPaid.js";
import { CusProductService } from "@/internal/customers/cusProducts/CusProductService.js";
import { InvoiceService } from "@/internal/invoices/InvoiceService.js";
import { getInvoiceItems } from "@/internal/invoices/invoiceUtils.js";
import { JobName } from "@/queue/JobName.js";
import { addTaskToQueue } from "@/queue/queueUtils.js";
import { nullish } from "@/utils/genUtils.js";
import {
getFullStripeInvoice,
@@ -16,282 +20,277 @@ import {
invoiceToSubId,
updateInvoiceIfExists,
} from "../stripeInvoiceUtils.js";
import { getStripeSubs } from "../stripeSubUtils.js";
import { addTaskToQueue } from "@/queue/queueUtils.js";
import { JobName } from "@/queue/JobName.js";
import { DrizzleCli } from "@/db/initDrizzle.js";
import { InvoiceService } from "@/internal/invoices/InvoiceService.js";
import { getInvoiceItems } from "@/internal/invoices/invoiceUtils.js";
import { handleInvoicePaidDiscount } from "./handleInvoicePaidDiscount.js";
import { handleInvoiceCheckoutPaid } from "@/internal/customers/attach/attachFunctions/invoiceCheckoutPaid/handleInvoiceCheckoutPaid.js";
import { lineItemInCusProduct } from "../stripeSubUtils/stripeSubItemUtils.js";
import { getStripeSubs } from "../stripeSubUtils.js";
import { createStripeCli } from "../utils.js";
import { handleInvoicePaidDiscount } from "./handleInvoicePaidDiscount.js";
const handleOneOffInvoicePaid = async ({
db,
stripeInvoice,
logger,
db,
stripeInvoice,
logger,
}: {
db: DrizzleCli;
stripeInvoice: Stripe.Invoice;
event: Stripe.Event;
logger: any;
db: DrizzleCli;
stripeInvoice: Stripe.Invoice;
event: Stripe.Event;
logger: any;
}) => {
// Search for invoice
const invoice = await InvoiceService.getByStripeId({
db,
stripeId: stripeInvoice.id!,
});
// Search for invoice
const invoice = await InvoiceService.getByStripeId({
db,
stripeId: stripeInvoice.id!,
});
if (!invoice) {
console.log(`Invoice not found`);
return;
}
if (!invoice) {
console.log(`Invoice not found`);
return;
}
// Update invoice status
await InvoiceService.updateByStripeId({
db,
stripeId: stripeInvoice.id!,
updates: {
status: stripeInvoice.status as InvoiceStatus,
hosted_invoice_url: stripeInvoice.hosted_invoice_url,
discounts: getInvoiceDiscounts({
expandedInvoice: stripeInvoice,
}),
},
});
// Update invoice status
await InvoiceService.updateByStripeId({
db,
stripeId: stripeInvoice.id!,
updates: {
status: stripeInvoice.status as InvoiceStatus,
hosted_invoice_url: stripeInvoice.hosted_invoice_url,
discounts: getInvoiceDiscounts({
expandedInvoice: stripeInvoice,
}),
},
});
console.log(`Updated one off invoice status to ${stripeInvoice.status}`);
console.log(`Updated one off invoice status to ${stripeInvoice.status}`);
};
const convertToChargeAutomatically = async ({
org,
env,
invoice,
activeCusProducts,
logger,
org,
env,
invoice,
activeCusProducts,
logger,
}: {
org: Organization;
env: AppEnv;
invoice: Stripe.Invoice;
activeCusProducts: FullCusProduct[];
logger: any;
org: Organization;
env: AppEnv;
invoice: Stripe.Invoice;
activeCusProducts: FullCusProduct[];
logger: any;
}) => {
const stripeCli = createStripeCli({ org, env });
const stripeCli = createStripeCli({ org, env });
const subs = await getStripeSubs({
stripeCli,
subIds: activeCusProducts.flatMap((p) => p.subscription_ids || []),
});
const subs = await getStripeSubs({
stripeCli,
subIds: activeCusProducts.flatMap((p) => p.subscription_ids || []),
});
const payments = invoice.payments;
const firstPayment = payments?.data?.[0];
const paymentIntentId = firstPayment?.payment?.payment_intent as string;
const payments = invoice.payments;
const firstPayment = payments?.data?.[0];
const paymentIntentId = firstPayment?.payment?.payment_intent as string;
if (
subs.every((s) => s.collection_method === "charge_automatically") ||
nullish(paymentIntentId)
) {
return;
}
if (
subs.every((s) => s.collection_method === "charge_automatically") ||
nullish(paymentIntentId)
) {
return;
}
// Get payment intent...
// Get payment intent...
// Try to attach payment method to subscription
try {
logger.info(`Converting to charge automatically`);
// 1. Get payment intent
const paymentIntent =
await stripeCli.paymentIntents.retrieve(paymentIntentId);
// Try to attach payment method to subscription
try {
logger.info(`Converting to charge automatically`);
// 1. Get payment intent
const paymentIntent =
await stripeCli.paymentIntents.retrieve(paymentIntentId);
// 2. Get payment method
const paymentMethod = await stripeCli.paymentMethods.retrieve(
paymentIntent.payment_method as string
);
// 2. Get payment method
const paymentMethod = await stripeCli.paymentMethods.retrieve(
paymentIntent.payment_method as string,
);
await stripeCli.paymentMethods.attach(paymentMethod.id, {
customer: invoice.customer as string,
});
await stripeCli.paymentMethods.attach(paymentMethod.id, {
customer: invoice.customer as string,
});
const batchUpdateSubs = [];
const updateSub = async (sub: Stripe.Subscription) => {
try {
await stripeCli.subscriptions.update(sub.id, {
collection_method: "charge_automatically",
default_payment_method: paymentMethod.id,
});
} catch (error) {
logger.warn(
`Convert to charge automatically: error updating subscription ${sub.id}`
);
logger.warn(error);
}
};
const batchUpdateSubs = [];
const updateSub = async (sub: Stripe.Subscription) => {
try {
await stripeCli.subscriptions.update(sub.id, {
collection_method: "charge_automatically",
default_payment_method: paymentMethod.id,
});
} catch (error) {
logger.warn(
`Convert to charge automatically: error updating subscription ${sub.id}`,
);
logger.warn(error);
}
};
for (const sub of subs) {
batchUpdateSubs.push(updateSub(sub));
}
for (const sub of subs) {
batchUpdateSubs.push(updateSub(sub));
}
await Promise.all(batchUpdateSubs);
await Promise.all(batchUpdateSubs);
logger.info("Convert to charge automatically successful!");
} catch (error) {
logger.warn(`Convert to charge automatically failed: ${error}`);
}
logger.info("Convert to charge automatically successful!");
} catch (error) {
logger.warn(`Convert to charge automatically failed: ${error}`);
}
};
export const handleInvoicePaid = async ({
db,
req,
org,
invoiceData,
env,
event,
db,
req,
org,
invoiceData,
env,
event,
}: {
db: DrizzleCli;
req: any;
org: Organization;
invoiceData: Stripe.Invoice;
env: AppEnv;
event: Stripe.Event;
db: DrizzleCli;
req: any;
org: Organization;
invoiceData: Stripe.Invoice;
env: AppEnv;
event: Stripe.Event;
}) => {
const logger = req.logtail;
const stripeCli = createStripeCli({ org, env });
const invoice = await getFullStripeInvoice({
stripeCli,
stripeId: invoiceData.id!,
expand: ["payments"],
});
const logger = req.logtail;
const stripeCli = createStripeCli({ org, env });
const invoice = await getFullStripeInvoice({
stripeCli,
stripeId: invoiceData.id!,
expand: ["payments"],
});
if (invoice.metadata?.autumn_metadata_id) {
await handleInvoiceCheckoutPaid({
req,
org,
env,
db,
stripeCli,
invoice,
});
}
if (invoice.metadata?.autumn_metadata_id) {
await handleInvoiceCheckoutPaid({
req,
org,
env,
db,
stripeCli,
invoice,
});
}
await handleInvoicePaidDiscount({
db,
expandedInvoice: invoice,
org,
env,
logger,
});
await handleInvoicePaidDiscount({
db,
expandedInvoice: invoice,
org,
env,
logger,
});
const subId = invoiceToSubId({ invoice });
if (subId) {
// Get customer product
const activeCusProducts = await CusProductService.getByStripeSubId({
db,
stripeSubId: subId,
orgId: org.id,
env,
});
const subId = invoiceToSubId({ invoice });
if (subId) {
// Get customer product
const activeCusProducts = await CusProductService.getByStripeSubId({
db,
stripeSubId: subId,
orgId: org.id,
env,
});
if (!activeCusProducts || activeCusProducts.length === 0) {
// TODO: Send alert
if (invoice.livemode) {
logger.warn(
`invoice.paid: customer product not found for invoice ${invoice.id}`
);
}
return;
}
if (!activeCusProducts || activeCusProducts.length === 0) {
// TODO: Send alert
if (invoice.livemode) {
logger.warn(
`invoice.paid: customer product not found for invoice ${invoice.id}`,
);
}
return;
}
if (org.config.convert_to_charge_automatically) {
await convertToChargeAutomatically({
org,
env,
invoice,
activeCusProducts,
logger,
});
}
if (org.config.convert_to_charge_automatically) {
await convertToChargeAutomatically({
org,
env,
invoice,
activeCusProducts,
logger,
});
}
const updated = await updateInvoiceIfExists({
db,
invoice,
});
const updated = await updateInvoiceIfExists({
db,
invoice,
});
if (!updated) {
let invoiceItems = await getInvoiceItems({
stripeInvoice: invoice,
prices: activeCusProducts.flatMap((p) =>
p.customer_prices.map((cpr: FullCustomerPrice) => cpr.price)
),
logger,
});
if (!updated) {
const invoiceItems = await getInvoiceItems({
stripeInvoice: invoice,
prices: activeCusProducts.flatMap((p) =>
p.customer_prices.map((cpr: FullCustomerPrice) => cpr.price),
),
logger,
});
const invoiceLines = invoice.lines.data;
let cusProducts: FullCusProduct[] = activeCusProducts;
try {
cusProducts = activeCusProducts.filter((cp) =>
invoiceLines.some((l) =>
lineItemInCusProduct({ cusProduct: cp, lineItem: l })
)
);
const invoiceLines = invoice.lines.data;
let cusProducts: FullCusProduct[] = activeCusProducts;
try {
cusProducts = activeCusProducts.filter((cp) =>
invoiceLines.some((l) =>
lineItemInCusProduct({ cusProduct: cp, lineItem: l }),
),
);
console.log(
"Invoice paid, filtered cus products:",
cusProducts.map((cp) => `${cp.product.name} - ${cp.product.id}`)
);
console.log(
"Invoice paid, filtered cus products:",
cusProducts.map((cp) => `${cp.product.name} - ${cp.product.id}`),
);
if (cusProducts.length == 0) {
cusProducts = activeCusProducts;
}
} catch (error) {
logger.error("Failed to filter cus products for invoice");
logger.error({ error });
}
if (cusProducts.length == 0) {
cusProducts = activeCusProducts;
}
} catch (error) {
logger.error("Failed to filter cus products for invoice");
logger.error({ error });
}
const internalEntityId = new Set(
cusProducts.map((cp) => cp.internal_entity_id)
);
const internalEntityId = new Set(
cusProducts.map((cp) => cp.internal_entity_id),
);
await InvoiceService.createInvoiceFromStripe({
db,
stripeInvoice: invoice,
internalCustomerId: activeCusProducts[0].internal_customer_id,
internalEntityId:
internalEntityId.size > 1
? undefined
: internalEntityId.values().next().value,
await InvoiceService.createInvoiceFromStripe({
db,
stripeInvoice: invoice,
internalCustomerId: activeCusProducts[0].internal_customer_id,
internalEntityId:
internalEntityId.size > 1
? undefined
: internalEntityId.values().next().value,
productIds: [...new Set(cusProducts.map((p) => p.product_id))],
internalProductIds: [
...new Set(cusProducts.map((p) => p.internal_product_id)),
],
org: org,
items: invoiceItems,
});
}
productIds: [...new Set(cusProducts.map((p) => p.product_id))],
internalProductIds: [
...new Set(cusProducts.map((p) => p.internal_product_id)),
],
org: org,
items: invoiceItems,
});
}
for (const cusProd of activeCusProducts) {
try {
await addTaskToQueue({
jobName: JobName.TriggerCheckoutReward,
payload: {
customer: cusProd.customer,
product: cusProd.product,
org,
env: cusProd.customer!.env,
subId: cusProd.subscription_ids?.[0],
},
});
} catch (error) {
logger.error(`invoice.paid: failed to trigger checkout reward check`);
logger.error(error);
}
}
} else {
await handleOneOffInvoicePaid({
db,
stripeInvoice: invoice,
event,
logger,
});
}
for (const cusProd of activeCusProducts) {
try {
await addTaskToQueue({
jobName: JobName.TriggerCheckoutReward,
payload: {
customer: cusProd.customer,
product: cusProd.product,
org,
env: cusProd.customer!.env,
subId: cusProd.subscription_ids?.[0],
},
});
} catch (error) {
logger.error(`invoice.paid: failed to trigger checkout reward check`);
logger.error(error);
}
}
} else {
await handleOneOffInvoicePaid({
db,
stripeInvoice: invoice,
event,
logger,
});
}
};

View File

@@ -1,181 +1,183 @@
import { DrizzleCli } from "@/db/initDrizzle.js";
import { RewardService } from "@/internal/rewards/RewardService.js";
import {
type AppEnv,
CouponDurationType,
Organization,
Reward,
type Organization,
type Reward,
RewardType,
} from "@autumn/shared";
import { AppEnv } from "@autumn/shared";
import { addMonths } from "date-fns";
import { Decimal } from "decimal.js";
import Stripe from "stripe";
import type Stripe from "stripe";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import { RewardService } from "@/internal/rewards/RewardService.js";
import { generateId } from "@/utils/genUtils.js";
import { getStripeNow } from "@/utils/scriptUtils/testClockUtils.js";
import {
deleteCouponFromCus,
deleteCouponFromSub,
} from "../stripeCouponUtils/deleteCouponFromCus.js";
import { createStripeCli } from "../utils.js";
import { Decimal } from "decimal.js";
import { generateId } from "@/utils/genUtils.js";
import { addMonths } from "date-fns";
import { getStripeNow } from "@/utils/scriptUtils/testClockUtils.js";
import { invoiceToSubId } from "../stripeInvoiceUtils.js";
import { createStripeCli } from "../utils.js";
export const handleInvoicePaidDiscount = async ({
db,
expandedInvoice,
org,
env,
logger,
db,
expandedInvoice,
org,
env,
logger,
}: {
db: DrizzleCli;
expandedInvoice: Stripe.Invoice;
org: Organization;
env: AppEnv;
logger: any;
db: DrizzleCli;
expandedInvoice: Stripe.Invoice;
org: Organization;
env: AppEnv;
logger: any;
}) => {
// Handle coupon
const stripeCli = createStripeCli({ org, env });
if (expandedInvoice.discounts.length === 0) {
return;
}
// Handle coupon
const stripeCli = createStripeCli({ org, env });
if (expandedInvoice.discounts.length === 0) {
return;
}
let stripeCus = await stripeCli.customers.retrieve(
expandedInvoice.customer as string
);
const stripeCus = await stripeCli.customers.retrieve(
expandedInvoice.customer as string,
);
try {
const totalDiscountAmounts = expandedInvoice.total_discount_amounts;
try {
const totalDiscountAmounts = expandedInvoice.total_discount_amounts;
// Log coupon information for debugging
for (const discount of expandedInvoice.discounts) {
if (typeof discount === "string") {
continue;
}
// Log coupon information for debugging
for (const discount of expandedInvoice.discounts) {
if (typeof discount === "string") {
continue;
}
const curCoupon = discount.coupon;
const curCoupon = discount.coupon;
if (!curCoupon) {
continue;
}
if (!curCoupon) {
continue;
}
const rollSuffixIndex = curCoupon.id.indexOf("_roll_");
const couponId =
rollSuffixIndex !== -1
? curCoupon.id.substring(0, rollSuffixIndex)
: curCoupon.id;
const rollSuffixIndex = curCoupon.id.indexOf("_roll_");
const couponId =
rollSuffixIndex !== -1
? curCoupon.id.substring(0, rollSuffixIndex)
: curCoupon.id;
const autumnReward: Reward | null = await RewardService.get({
db,
idOrInternalId: couponId,
orgId: org.id,
env,
});
const autumnReward: Reward | null = await RewardService.get({
db,
idOrInternalId: couponId,
orgId: org.id,
env,
});
let shouldRollover =
autumnReward && autumnReward.type == RewardType.InvoiceCredits;
const shouldRollover =
autumnReward &&
(autumnReward.type === RewardType.InvoiceCredits ||
autumnReward.type === RewardType.FreeProduct);
if (!shouldRollover) {
continue;
}
if (!shouldRollover) {
continue;
}
// Get ID of coupon
const originalCoupon = await stripeCli.coupons.retrieve(couponId, {
expand: ["applies_to"],
});
// Get ID of coupon
const originalCoupon = await stripeCli.coupons.retrieve(couponId, {
expand: ["applies_to"],
});
// 1. New amount:
const curAmount = discount.coupon.amount_off;
// 1. New amount:
const curAmount = discount.coupon.amount_off;
const amountUsed = totalDiscountAmounts?.find(
(item) => item.discount === discount.id
)?.amount;
const amountUsed = totalDiscountAmounts?.find(
(item) => item.discount === discount.id,
)?.amount;
const newAmount = new Decimal(curAmount!).sub(amountUsed!).toNumber();
const newAmount = new Decimal(curAmount!).sub(amountUsed!).toNumber();
let curExpiresAt = curCoupon.metadata?.expires_at
? Number(curCoupon.metadata.expires_at)
: null;
const curExpiresAt = curCoupon.metadata?.expires_at
? Number(curCoupon.metadata.expires_at)
: null;
let discountFinished = newAmount <= 0;
const discountFinished = newAmount <= 0;
let now = await getStripeNow({
stripeCli,
stripeCus: stripeCus as Stripe.Customer,
});
const now = await getStripeNow({
stripeCli,
stripeCus: stripeCus as Stripe.Customer,
});
let expired = curExpiresAt && curExpiresAt < now;
const subId = invoiceToSubId({ invoice: expandedInvoice });
const expired = curExpiresAt && curExpiresAt < now;
const subId = invoiceToSubId({ invoice: expandedInvoice });
if (discountFinished || expired) {
logger.info(
`Coupon ${couponId}, stripeCus: ${stripeCus.id}: credits used up or expired. discountFinished: ${discountFinished}, expired: ${expired}`
);
if (discountFinished || expired) {
logger.info(
`Coupon ${couponId}, stripeCus: ${stripeCus.id}: credits used up or expired. discountFinished: ${discountFinished}, expired: ${expired}`,
);
if (subId) {
await deleteCouponFromCus({
stripeCli,
stripeSubId: subId,
stripeCusId: expandedInvoice.customer as string,
discountId: discount.id,
logger,
});
}
if (subId) {
await deleteCouponFromCus({
stripeCli,
stripeSubId: subId,
stripeCusId: expandedInvoice.customer as string,
discountId: discount.id,
logger,
});
}
continue;
}
continue;
}
logger.info(
`Coupon ${couponId}, stripeCus: ${stripeCus.id}, updating amount from ${curAmount} to ${newAmount}`
);
logger.info(
`Coupon ${couponId}, stripeCus: ${stripeCus.id}, updating amount from ${curAmount} to ${newAmount}`,
);
// Set expiry date
let expiresAt = curCoupon.metadata?.expires_at || null;
let discountConfig = autumnReward?.discount_config;
if (discountConfig?.duration_type == CouponDurationType.Months) {
expiresAt = addMonths(new Date(), discountConfig.duration_value)
.getTime()
.toString();
}
// Set expiry date
let expiresAt = curCoupon.metadata?.expires_at || null;
const discountConfig = autumnReward?.discount_config;
if (discountConfig?.duration_type === CouponDurationType.Months) {
expiresAt = addMonths(new Date(), discountConfig.duration_value)
.getTime()
.toString();
}
const newCoupon = await stripeCli.coupons.create({
id: `${couponId}_${generateId("roll")}`,
name: discount.coupon.name as string,
amount_off: newAmount,
currency: expandedInvoice.currency,
duration: "once",
applies_to: originalCoupon.applies_to,
metadata: {
expires_at: expiresAt,
},
});
const newCoupon = await stripeCli.coupons.create({
id: `${couponId}_${generateId("roll")}`,
name: discount.coupon.name as string,
amount_off: newAmount,
currency: expandedInvoice.currency,
duration: "once",
applies_to: originalCoupon.applies_to,
metadata: {
expires_at: expiresAt,
},
});
const legacyStripeCli = createStripeCli({
org,
env,
legacyVersion: true,
});
const legacyStripeCli = createStripeCli({
org,
env,
legacyVersion: true,
});
await legacyStripeCli.rawRequest(
"POST",
`/v1/customers/${expandedInvoice.customer}`,
{
coupon: newCoupon.id,
}
);
await legacyStripeCli.rawRequest(
"POST",
`/v1/customers/${expandedInvoice.customer}`,
{
coupon: newCoupon.id,
},
);
await stripeCli.coupons.del(newCoupon.id);
await stripeCli.coupons.del(newCoupon.id);
if (subId) {
await deleteCouponFromSub({
stripeCli,
stripeSubId: subId,
discountId: discount.id,
logger,
});
}
}
} catch (error) {
logger.error("invoice.paid: error updating coupon");
logger.error(error);
}
if (subId) {
await deleteCouponFromSub({
stripeCli,
stripeSubId: subId,
discountId: discount.id,
logger,
});
}
}
} catch (error) {
logger.error("invoice.paid: error updating coupon");
logger.error(error);
}
};

View File

@@ -0,0 +1,20 @@
import { RewardRedemptionService } from "@/internal/rewards/RewardRedemptionService.js";
import { routeHandler } from "@/utils/routerUtils.js";
export default async (req: any, res: any) =>
routeHandler({
req,
res,
action: "get redemption by id",
handler: async (req, res) => {
const { db } = req;
const { redemptionId } = req.params;
const redemption = await RewardRedemptionService.getById({
db,
id: redemptionId,
});
res.status(200).json(redemption);
},
});

View File

@@ -0,0 +1,85 @@
import { ErrCode } from "@autumn/shared";
import { CusService } from "@/internal/customers/CusService.js";
import { RewardProgramService } from "@/internal/rewards/RewardProgramService.js";
import { generateReferralCode } from "@/internal/rewards/referralUtils.js";
import RecaseError from "@/utils/errorUtils.js";
import { generateId } from "@/utils/genUtils.js";
import { routeHandler } from "@/utils/routerUtils.js";
export default async (req: any, res: any) =>
routeHandler({
req,
res,
action: "get referral code",
handler: async (req, res) => {
const { orgId, env, db } = req;
const { program_id: rewardProgramId, customer_id: customerId } = req.body;
const [rewardProgram, customer] = await Promise.all([
RewardProgramService.get({
db,
id: rewardProgramId,
orgId,
env,
errorIfNotFound: true,
}),
CusService.get({
db: req.db,
orgId,
env,
idOrInternalId: customerId,
}),
]);
if (!customer) {
throw new RecaseError({
message: "Customer not found",
statusCode: 404,
code: ErrCode.CustomerNotFound,
});
}
if (!rewardProgram) {
throw new RecaseError({
message: "Reward program not found",
statusCode: 404,
code: ErrCode.RewardProgramNotFound,
});
}
// Get referral code by customer and reward trigger
let referralCode =
await RewardProgramService.getCodeByCustomerAndRewardProgram({
db,
orgId,
env,
internalCustomerId: customer.internal_id,
internalRewardProgramId: rewardProgram.internal_id,
});
if (!referralCode) {
const code = generateReferralCode();
referralCode = {
code,
org_id: orgId,
env,
internal_customer_id: customer.internal_id,
internal_reward_program_id: rewardProgram.internal_id,
id: generateId("rc"),
created_at: Date.now(),
};
referralCode = await RewardProgramService.createReferralCode({
db,
data: referralCode,
});
}
res.status(200).json({
code: referralCode.code,
customer_id: customer.id,
created_at: referralCode.created_at,
});
},
});

View File

@@ -0,0 +1,219 @@
import {
ErrCode,
RewardCategory,
type RewardRedemption,
RewardTriggerEvent,
} from "@autumn/shared";
import { parseReqForAction } from "@/internal/analytics/actionUtils.js";
import { CusService } from "@/internal/customers/CusService.js";
import { OrgService } from "@/internal/orgs/OrgService.js";
import { RewardProgramService } from "@/internal/rewards/RewardProgramService.js";
import { RewardRedemptionService } from "@/internal/rewards/RewardRedemptionService.js";
import { RewardService } from "@/internal/rewards/RewardService.js";
import { triggerRedemption } from "@/internal/rewards/referralUtils.js";
import { triggerFreeProduct } from "@/internal/rewards/referralUtils/triggerFreeProduct.js";
import { getRewardCat } from "@/internal/rewards/rewardUtils.js";
import RecaseError from "@/utils/errorUtils.js";
import { generateId, notNullish } from "@/utils/genUtils.js";
import type { ExtendedRequest } from "@/utils/models/Request.js";
import { routeHandler } from "@/utils/routerUtils.js";
export default async (req: any, res: any) =>
routeHandler({
req,
res,
action: "redeem referral code",
handler: async (req, res) => {
const { orgId, env, logtail: logger, db } = req;
const { code, customer_id: customerId } = req.body;
// 1. Get redeemed by customer, and referral code
const [customer, referralCode, org] = await Promise.all([
CusService.get({
db,
orgId,
env,
idOrInternalId: customerId,
}),
RewardProgramService.getReferralCode({
db,
orgId,
env,
code,
withRewardProgram: true,
}),
OrgService.getFromReq(req),
]);
if (!customer) {
throw new RecaseError({
message: "Customer not found",
statusCode: 404,
code: ErrCode.CustomerNotFound,
});
}
// 2. Check that code has not reached max redemptions
const redemptionCount = await RewardProgramService.getCodeRedemptionCount(
{
db,
referralCodeId: referralCode.id,
}
);
if (
referralCode.reward_program.max_redemptions &&
redemptionCount >= referralCode.reward_program.max_redemptions
) {
throw new RecaseError({
message: "Referral code has reached max redemptions",
statusCode: 400,
code: ErrCode.ReferralCodeMaxRedemptionsReached,
});
}
// 3. Check that customer has not already redeemed a code in this referral program
const existingRedemptions = await RewardRedemptionService.getByCustomer({
db,
internalCustomerId: customer.internal_id,
internalRewardProgramId: referralCode.internal_reward_program_id,
});
if (existingRedemptions.length > 0) {
throw new RecaseError({
message: `Customer ${customer.id} has already redeemed a code in this referral program`,
statusCode: 400,
code: ErrCode.CustomerAlreadyRedeemedReferralCode,
});
}
// Don't let customer redeem their own code
const codeCustomer = await CusService.getByInternalId({
db: req.db,
internalId: referralCode.internal_customer_id,
});
if (!codeCustomer) {
throw new RecaseError({
message: "Referral code customer not found",
statusCode: 404,
code: ErrCode.CustomerNotFound,
});
}
if (
codeCustomer.id === customer.id ||
(notNullish(codeCustomer.fingerprint) &&
codeCustomer.fingerprint === customer.fingerprint)
) {
throw new RecaseError({
message: "Customer cannot redeem their own code",
statusCode: 400,
code: ErrCode.CustomerCannotRedeemOwnCode,
});
}
// 4. Insert redemption into db
let redemption: RewardRedemption = {
id: generateId("rr"),
referral_code_id: referralCode.id,
internal_customer_id: customer.internal_id, // redeemed by customer
internal_reward_program_id: referralCode.internal_reward_program_id,
created_at: Date.now(),
triggered:
referralCode.reward_program.when ===
RewardTriggerEvent.CustomerCreation,
applied: false,
updated_at: Date.now(),
redeemer_applied: false,
};
redemption = await RewardRedemptionService.insert({
db,
rewardRedemption: redemption,
});
// 5. If reward trigger when is immediate:
const { reward_program } = referralCode;
const redeemRewardNow =
referralCode.reward_program.when ===
RewardTriggerEvent.CustomerCreation;
if (redeemRewardNow) {
const reward = await RewardService.get({
db,
orgId,
env,
idOrInternalId: reward_program.internal_reward_id,
});
if (!reward) {
throw new RecaseError({
message: `Reward ${reward_program.internal_reward_id} not found`,
statusCode: 404,
code: ErrCode.RewardNotFound,
});
}
const rewardCat = getRewardCat(reward);
if (rewardCat === RewardCategory.FreeProduct) {
await triggerFreeProduct({
req: parseReqForAction(req) as ExtendedRequest,
db,
referralCode,
redeemer: customer,
rewardProgram: reward_program,
org,
env,
logger,
redemption,
});
} else {
await triggerRedemption({
db,
referralCode,
org,
env,
logger,
reward,
redemption,
});
}
}
return res.status(200).json({
id: redemption.id,
customer_id: customer.id,
reward_id: reward_program.reward.id,
referrer: {
id: codeCustomer.id,
name: codeCustomer.name,
email: codeCustomer.email,
created_at: codeCustomer.created_at,
},
redeemer: {
id: customer.id,
name: customer.name,
email: customer.email,
created_at: customer.created_at,
},
});
},
});
// res.status(200).json({
// id: redemption.id,
// customer_id: customer.id,
// reward_id: reward_program.reward.id,
// referrer: {
// id: codeCustomer.id,
// name: codeCustomer.name,
// email: codeCustomer.email,
// created_at: codeCustomer.created_at,
// code: applications.referrer.cause,
// },
// redeemer: {
// code: applications.redeemer.cause,
// ...applications.redeemer.meta,
// },
// });

View File

@@ -0,0 +1,3 @@
export { default as handleGetRedemption } from "./handleGetRedemption.js";
export { default as handleGetReferralCode } from "./handleGetReferralCode.js";
export { default as handleRedeemReferral } from "./handleRedeemReferral.js";

View File

@@ -0,0 +1,85 @@
import {
CreateRewardProgram,
ErrCode,
RewardTriggerEvent,
} from "@autumn/shared";
import { RewardProgramService } from "@/internal/rewards/RewardProgramService.js";
import { constructRewardProgram } from "@/internal/rewards/rewardTriggerUtils.js";
import RecaseError from "@/utils/errorUtils.js";
import { nullish } from "@/utils/genUtils.js";
import { routeHandler } from "@/utils/routerUtils.js";
export default async (req: any, res: any) =>
routeHandler({
req,
res,
action: "create reward trigger",
handler: async (req, res) => {
const { orgId, env, db } = req;
const body = req.body;
if (!body.internal_reward_id) {
throw new RecaseError({
message: "Please select a reward to link this program to",
code: ErrCode.InvalidRequest,
statusCode: 400,
});
}
if (!body.id) {
throw new RecaseError({
message: "Please give this program an ID",
code: ErrCode.InvalidRequest,
statusCode: 400,
});
}
const existingProgram = await RewardProgramService.get({
db,
id: body.id,
orgId,
env,
});
if (existingProgram) {
throw new RecaseError({
message: `Program with ID ${body.id} already exists`,
code: ErrCode.InvalidRequest,
statusCode: 400,
});
}
const rewardProgram = constructRewardProgram({
rewardProgramData: CreateRewardProgram.parse(req.body),
orgId,
env,
});
// Fetch reward ID
// let reward = await RewardService.get({
// db,
// id: rewardProgram.internal_reward_id,
// orgId,
// env,
// });
if (
rewardProgram.when === RewardTriggerEvent.Checkout &&
(nullish(rewardProgram.product_ids) ||
rewardProgram.product_ids!.length === 0)
) {
throw new RecaseError({
message: "If redeem on checkout, must specify at least one product",
code: ErrCode.InvalidRequest,
statusCode: 400,
});
}
const createdRewardProgram = await RewardProgramService.create({
db,
data: rewardProgram,
});
return res.status(200).json(createdRewardProgram);
},
});

View File

@@ -0,0 +1,85 @@
import {
CreateRewardProgram,
ErrCode,
RewardTriggerEvent,
} from "@autumn/shared";
import { RewardProgramService } from "@/internal/rewards/RewardProgramService.js";
import { constructRewardProgram } from "@/internal/rewards/rewardTriggerUtils.js";
import RecaseError from "@/utils/errorUtils.js";
import { nullish } from "@/utils/genUtils.js";
import { routeHandler } from "@/utils/routerUtils.js";
export default async (req: any, res: any) =>
routeHandler({
req,
res,
action: "create trigger",
handler: async (req, res) => {
const { orgId, env, db } = req;
const body = req.body;
if (!body.internal_reward_id) {
throw new RecaseError({
message: "Please select a reward to link this program to",
code: ErrCode.InvalidRequest,
statusCode: 400,
});
}
if (!body.id) {
throw new RecaseError({
message: "Please give this program an ID",
code: ErrCode.InvalidRequest,
statusCode: 400,
});
}
const existingProgram = await RewardProgramService.get({
db,
id: body.id,
orgId,
env,
});
if (existingProgram) {
throw new RecaseError({
message: `Program with ID ${body.id} already exists`,
code: ErrCode.InvalidRequest,
statusCode: 400,
});
}
const rewardProgram = constructRewardProgram({
rewardProgramData: CreateRewardProgram.parse(req.body),
orgId,
env,
});
// Fetch reward ID
// let reward = await RewardService.get({
// db,
// id: rewardProgram.internal_reward_id,
// orgId,
// env,
// });
if (
rewardProgram.when === RewardTriggerEvent.Checkout &&
(nullish(rewardProgram.product_ids) ||
rewardProgram.product_ids!.length === 0)
) {
throw new RecaseError({
message: "If redeem on checkout, must specify at least one product",
code: ErrCode.InvalidRequest,
statusCode: 400,
});
}
const createdRewardProgram = await RewardProgramService.create({
db,
data: rewardProgram,
});
return res.status(200).json(createdRewardProgram);
},
});

View File

@@ -0,0 +1,22 @@
import { RewardProgramService } from "@/internal/rewards/RewardProgramService.js";
import { routeHandler } from "@/utils/routerUtils.js";
export default async (req: any, res: any) =>
routeHandler({
req,
res,
action: "delete reward scheme",
handler: async (req, res) => {
const { orgId, env, db } = req;
const { id } = req.params;
const rewardProgram = await RewardProgramService.delete({
db,
id,
orgId,
env,
});
return res.status(200).json(rewardProgram);
},
});

View File

@@ -0,0 +1,3 @@
export { default as handleCreateRewardProgram } from "./handleCreateRewardProgram.js";
export { default as handleCreateTrigger } from "./handleCreateTrigger.js";
export { default as handleDeleteRewardProgram } from "./handleDeleteRewardProgram.js";

View File

@@ -0,0 +1,103 @@
import {
CreateRewardSchema,
isFixedPrice,
RewardCategory,
} from "@autumn/shared";
import { createStripeCoupon } from "@/external/stripe/stripeCouponUtils/stripeCouponUtils.js";
import { OrgService } from "@/internal/orgs/OrgService.js";
import { ProductService } from "@/internal/products/ProductService.js";
import { PriceService } from "@/internal/products/prices/PriceService.js";
import { pricesOnlyOneOff } from "@/internal/products/prices/priceUtils.js";
import { isFreeProduct } from "@/internal/products/productUtils.js";
import { RewardService } from "@/internal/rewards/RewardService.js";
import {
constructReward,
getRewardCat,
initRewardStripePrices,
} from "@/internal/rewards/rewardUtils.js";
import { routeHandler } from "@/utils/routerUtils.js";
export default async (req: any, res: any) =>
routeHandler({
req,
res,
action: "create coupon",
handler: async (req, res) => {
const { db, orgId, env, logtail: logger } = req;
const rewardBody = req.body;
const rewardData = CreateRewardSchema.parse(rewardBody);
const org = await OrgService.getFromReq(req);
const newReward = constructReward({
reward: rewardData,
orgId,
env,
});
if (getRewardCat(newReward) === RewardCategory.Discount) {
const discountConfig = newReward.discount_config;
// Get prices for coupon
const [prices] = await Promise.all([
PriceService.getInIds({
db,
ids: discountConfig!.price_ids || [],
}),
]);
await initRewardStripePrices({
db,
prices,
org,
env,
logger,
});
await createStripeCoupon({
reward: newReward,
org,
env,
prices,
logger,
legacyVersion: req.query.legacyStripe === "true",
});
}
if (getRewardCat(newReward) === RewardCategory.FreeProduct) {
// 1. Check if product is paid
const fullProduct = await ProductService.getFull({
db,
idOrInternalId: newReward.free_product_id!,
orgId: org.id,
env,
});
if (!isFreeProduct(fullProduct.prices)) {
// For one-off products, include all prices; for recurring products, only fixed prices
const isProductOneOff = pricesOnlyOneOff(fullProduct.prices);
const relevantPrices = isProductOneOff
? fullProduct.prices // Include all prices for one-off products
: fullProduct.prices.filter((price) => isFixedPrice({ price })); // Only fixed prices for recurring products
await createStripeCoupon({
reward: newReward,
org,
env,
prices: relevantPrices.map((price) => ({
...price,
product: fullProduct,
})),
logger,
});
}
}
const insertedCoupon = await RewardService.insert({
db,
data: newReward,
});
res.status(200).json(insertedCoupon);
},
});

View File

@@ -0,0 +1,55 @@
import { ErrCode } from "@autumn/shared";
import { createStripeCli } from "@/external/stripe/utils.js";
import { OrgService } from "@/internal/orgs/OrgService.js";
import { RewardService } from "@/internal/rewards/RewardService.js";
import RecaseError from "@/utils/errorUtils.js";
import { routeHandler } from "@/utils/routerUtils.js";
export default async (req: any, res: any) =>
routeHandler({
req,
res,
action: "delete reward",
handler: async (req, res) => {
const { id } = req.params;
const { orgId, env, db } = req;
const org = await OrgService.getFromReq(req);
const stripeCli = createStripeCli({
org,
env,
});
const reward = await RewardService.get({
db,
idOrInternalId: id,
orgId,
env,
});
if (!reward) {
throw new RecaseError({
message: `Reward ${id} not found`,
code: ErrCode.InvalidRequest,
});
}
try {
await stripeCli.coupons.del(reward.id);
} catch (error) {
console.log(`Failed to delete coupon from stripe: ${(error as { message: string }).message}`);
}
await RewardService.delete({
db,
internalId: reward.internal_id,
env,
orgId,
});
res.status(200).json({
success: true,
message: "Reward deleted successfully",
});
},
});

View File

@@ -0,0 +1,21 @@
import { RewardService } from "@/internal/rewards/RewardService.js";
import { routeHandler } from "@/utils/routerUtils.js";
export default async (req: any, res: any) => routeHandler({
req,
res,
action: "get reward",
handler: async (req, res) => {
const { id } = req.params;
const { orgId, env, db } = req;
const reward = await RewardService.get({
db,
idOrInternalId: id,
orgId,
env,
});
res.status(200).json(reward);
}
});

View File

@@ -0,0 +1,110 @@
import { ErrCode, PriceType, RewardCategory } from "@autumn/shared";
import { createStripeCoupon } from "@/external/stripe/stripeCouponUtils/stripeCouponUtils.js";
import { createStripeCli } from "@/external/stripe/utils.js";
import { OrgService } from "@/internal/orgs/OrgService.js";
import { ProductService } from "@/internal/products/ProductService.js";
import { PriceService } from "@/internal/products/prices/PriceService.js";
import { RewardService } from "@/internal/rewards/RewardService.js";
import { getRewardCat } from "@/internal/rewards/rewardUtils.js";
import RecaseError from "@/utils/errorUtils.js";
import { routeHandler } from "@/utils/routerUtils.js";
export default async (req: any, res: any) =>
routeHandler({
req,
res,
action: "update coupon",
handler: async (req, res) => {
const { internalId } = req.params;
const { orgId, env, db, logtail: logger } = req;
const rewardBody = req.body;
const org = await OrgService.getFromReq(req);
const stripeCli = createStripeCli({
org,
env,
});
const reward = await RewardService.get({
db,
idOrInternalId: internalId,
orgId,
env,
});
if (!reward) {
throw new RecaseError({
message: `Reward ${internalId} not found`,
code: ErrCode.InvalidRequest,
});
}
// Determine prices depending on reward category
const rewardCat = getRewardCat(rewardBody);
let prices: any[] = [];
if (rewardCat === RewardCategory.Discount) {
const stripePriceIds =
rewardBody.discount_config?.price_ids ??
reward.discount_config?.price_ids ??
[];
const byStripeId = await PriceService.getByStripeIds({
db,
stripePriceIds,
});
prices = stripePriceIds
.map((id: string) => byStripeId[id])
.filter(Boolean);
} else if (rewardCat === RewardCategory.FreeProduct) {
const freeProductId =
rewardBody.free_product_id ?? reward.free_product_id;
if (freeProductId) {
const fullProduct = await ProductService.getFull({
db,
idOrInternalId: freeProductId,
orgId: org.id,
env,
});
prices = fullProduct.prices
.map((price) => ({
...price,
product: fullProduct,
}))
.filter((x) => x.config?.type === PriceType.Fixed);
}
}
// 1. Delete old prices from stripe
try {
await stripeCli.coupons.del(reward.id);
await stripeCli.coupons.del(reward.internal_id);
} catch (_) {
// console.log(`Failed to delete coupon from stripe: ${error.message}`);
}
if (
rewardCat === RewardCategory.Discount ||
(rewardCat === RewardCategory.FreeProduct && prices.length > 0)
) {
await createStripeCoupon({
reward: rewardBody,
org,
env,
prices,
logger,
legacyVersion: req.query.legacyStripe === "true",
});
}
// 3. Update coupon in db
const updatedCoupon = await RewardService.update({
db,
internalId: reward.internal_id,
env,
orgId,
update: rewardBody,
});
res.status(200).json(updatedCoupon);
},
});

View File

@@ -0,0 +1,6 @@
import handleCreateCoupon from "./handleCreateCoupon.js";
import handleDeleteCoupon from "./handleDeleteCoupon.js";
import handleGetCoupon from "./handleGetCoupon.js";
import handleUpdateCoupon from "./handleUpdateCoupon.js";
export { handleCreateCoupon, handleDeleteCoupon, handleGetCoupon, handleUpdateCoupon };

View File

@@ -1,301 +1,17 @@
import { CusService } from "@/internal/customers/CusService.js";
import express, { type Router } from "express";
import {
generateReferralCode,
triggerFreeProduct,
triggerRedemption,
} from "@/internal/rewards/referralUtils.js";
import { RewardRedemptionService } from "@/internal/rewards/RewardRedemptionService.js";
import RecaseError from "@/utils/errorUtils.js";
import { generateId, notNullish } from "@/utils/genUtils.js";
import { routeHandler } from "@/utils/routerUtils.js";
import { ErrCode, RewardCategory, RewardTriggerEvent } from "@autumn/shared";
import express, { Router } from "express";
import { RewardRedemption } from "@autumn/shared";
import { OrgService } from "@/internal/orgs/OrgService.js";
import { RewardProgramService } from "@/internal/rewards/RewardProgramService.js";
import { getRewardCat } from "@/internal/rewards/rewardUtils.js";
import { RewardService } from "@/internal/rewards/RewardService.js";
import { parseReqForAction } from "@/internal/analytics/actionUtils.js";
import { ExtendedRequest } from "@/utils/models/Request.js";
handleGetRedemption,
handleGetReferralCode,
handleRedeemReferral,
} from "./handlers/referrals/index.js";
export const referralRouter: Router = express.Router();
// 1. Get referral code
referralRouter.post("/code", (req, res) =>
routeHandler({
req,
res,
action: "get referral code",
handler: async (req: any, res: any) => {
const { orgId, env, logtail: logger, db } = req;
const { program_id: rewardProgramId, customer_id: customerId } = req.body;
referralRouter.post("/code", handleGetReferralCode);
let [rewardProgram, customer] = await Promise.all([
RewardProgramService.get({
db,
id: rewardProgramId,
orgId,
env,
errorIfNotFound: true,
}),
CusService.get({
db: req.db,
orgId,
env,
idOrInternalId: customerId,
}),
]);
if (!customer) {
throw new RecaseError({
message: "Customer not found",
statusCode: 404,
code: ErrCode.CustomerNotFound,
});
}
if (!rewardProgram) {
throw new RecaseError({
message: "Reward program not found",
statusCode: 404,
code: ErrCode.RewardProgramNotFound,
});
}
// Get referral code by customer and reward trigger
let referralCode =
await RewardProgramService.getCodeByCustomerAndRewardProgram({
db,
orgId,
env,
internalCustomerId: customer.internal_id,
internalRewardProgramId: rewardProgram.internal_id,
});
if (!referralCode) {
const code = generateReferralCode();
referralCode = {
code,
org_id: orgId,
env,
internal_customer_id: customer.internal_id,
internal_reward_program_id: rewardProgram.internal_id,
id: generateId("rc"),
created_at: Date.now(),
};
referralCode = await RewardProgramService.createReferralCode({
db,
data: referralCode,
});
}
res.status(200).json({
code: referralCode.code,
customer_id: customer.id,
created_at: referralCode.created_at,
});
},
})
);
referralRouter.post("/redeem", (req, res) =>
routeHandler({
req,
res,
action: "redeem referral code",
handler: async (req: any, res: any) => {
const { orgId, env, logtail: logger, db } = req;
// const { referral_id: rewardTriggerId } = req.params;
const { code, customer_id: customerId } = req.body;
// 1. Get redeemed by customer, and referral code
let [customer, referralCode, org] = await Promise.all([
CusService.get({
db,
orgId,
env,
idOrInternalId: customerId,
}),
RewardProgramService.getReferralCode({
db,
orgId,
env,
code,
withRewardProgram: true,
}),
OrgService.getFromReq(req),
]);
if (!customer) {
throw new RecaseError({
message: "Customer not found",
statusCode: 404,
code: ErrCode.CustomerNotFound,
});
}
// 2. Check that code has not reached max redemptions
let redemptionCount = await RewardProgramService.getCodeRedemptionCount({
db,
referralCodeId: referralCode.id,
});
if (
referralCode.reward_program.max_redemptions &&
redemptionCount >= referralCode.reward_program.max_redemptions
) {
throw new RecaseError({
message: "Referral code has reached max redemptions",
statusCode: 400,
code: ErrCode.ReferralCodeMaxRedemptionsReached,
});
}
// 3. Check that customer has not already redeemed a code in this referral program
let existingRedemptions = await RewardRedemptionService.getByCustomer({
db,
internalCustomerId: customer.internal_id,
internalRewardProgramId: referralCode.internal_reward_program_id,
});
if (existingRedemptions.length > 0) {
throw new RecaseError({
message: `Customer ${customer.id} has already redeemed a code in this referral program`,
statusCode: 400,
code: ErrCode.CustomerAlreadyRedeemedReferralCode,
});
}
// Don't let customer redeem their own code
let codeCustomer = await CusService.getByInternalId({
db: req.db,
internalId: referralCode.internal_customer_id,
});
if (!codeCustomer) {
throw new RecaseError({
message: "Referral code customer not found",
statusCode: 404,
code: ErrCode.CustomerNotFound,
});
}
if (
codeCustomer.id === customer.id ||
(notNullish(codeCustomer.fingerprint) &&
codeCustomer.fingerprint === customer.fingerprint)
) {
throw new RecaseError({
message: "Customer cannot redeem their own code",
statusCode: 400,
code: ErrCode.CustomerCannotRedeemOwnCode,
});
}
// 4. Insert redemption into db
let redemption: RewardRedemption = {
id: generateId("rr"),
referral_code_id: referralCode.id,
internal_customer_id: customer.internal_id, // redeemed by customer
internal_reward_program_id: referralCode.internal_reward_program_id,
created_at: Date.now(),
triggered:
referralCode.reward_program.when ===
RewardTriggerEvent.CustomerCreation,
applied: false,
updated_at: Date.now(),
};
redemption = await RewardRedemptionService.insert({
db,
rewardRedemption: redemption,
});
// 5. If reward trigger when is immediate:
let { reward_program } = referralCode;
if (
referralCode.reward_program.when === RewardTriggerEvent.CustomerCreation
) {
const reward = await RewardService.get({
db,
orgId,
env,
idOrInternalId: reward_program.internal_reward_id,
});
if (!reward) {
throw new RecaseError({
message: `Reward ${reward_program.internal_reward_id} not found`,
statusCode: 404,
code: ErrCode.RewardNotFound,
});
}
let rewardCat = getRewardCat(reward);
if (rewardCat === RewardCategory.FreeProduct) {
await triggerFreeProduct({
req: parseReqForAction(req) as ExtendedRequest,
db,
referralCode,
redeemer: customer,
rewardProgram: reward_program,
org,
env,
logger,
redemption,
});
} else {
await triggerRedemption({
db,
referralCode,
org,
env,
logger,
reward,
redemption,
});
}
}
// Add coupon to customer?
res.status(200).json({
id: redemption.id,
customer_id: customer.id,
// triggered: redemption?.applied,
// applied: redemption?.applied,
reward_id: reward_program.reward.id,
referrer: {
id: codeCustomer.id,
name: codeCustomer.name,
email: codeCustomer.email,
created_at: codeCustomer.created_at,
},
});
},
})
);
referralRouter.post("/redeem", handleRedeemReferral);
export const redemptionRouter: Router = express.Router();
redemptionRouter.get("/:redemptionId", (req, res) =>
routeHandler({
req,
res,
action: "get redemption by id",
handler: async (req: any, res: any) => {
const { db } = req;
const { redemptionId } = req.params;
let redemption = await RewardRedemptionService.getById({
db,
id: redemptionId,
});
res.status(200).json(redemption);
},
})
);
redemptionRouter.get("/:redemptionId", handleGetRedemption);

View File

@@ -1,111 +1,8 @@
import { RewardProgramService } from "@/internal/rewards/RewardProgramService.js";
import { RewardService } from "@/internal/rewards/RewardService.js";
import { constructRewardProgram } from "@/internal/rewards/rewardTriggerUtils.js";
import RecaseError from "@/utils/errorUtils.js";
import { nullish } from "@/utils/genUtils.js";
import { routeHandler } from "@/utils/routerUtils.js";
import {
CreateRewardProgram,
ErrCode,
RewardTriggerEvent,
} from "@autumn/shared";
import express, { Router } from "express";
import express, { type Router } from "express";
import { handleCreateRewardProgram, handleDeleteRewardProgram } from "./handlers/rewardPrograms/index.js";
export const rewardProgramRouter: Router = express.Router();
rewardProgramRouter.post("", (req, res) =>
routeHandler({
req,
res,
action: "create reward trigger",
handler: async (req: any, res: any) => {
const { orgId, env, db } = req;
const body = req.body;
rewardProgramRouter.post("", handleCreateRewardProgram);
if (!body.internal_reward_id) {
throw new RecaseError({
message: "Please select a reward to link this program to",
code: ErrCode.InvalidRequest,
statusCode: 400,
});
}
if (!body.id) {
throw new RecaseError({
message: "Please give this program an ID",
code: ErrCode.InvalidRequest,
statusCode: 400,
});
}
let existingProgram = await RewardProgramService.get({
db,
id: body.id,
orgId,
env,
});
if (existingProgram) {
throw new RecaseError({
message: `Program with ID ${body.id} already exists`,
code: ErrCode.InvalidRequest,
statusCode: 400,
});
}
const rewardProgram = constructRewardProgram({
rewardProgramData: CreateRewardProgram.parse(req.body),
orgId,
env,
});
// Fetch reward ID
// let reward = await RewardService.get({
// db,
// id: rewardProgram.internal_reward_id,
// orgId,
// env,
// });
if (
rewardProgram.when == RewardTriggerEvent.Checkout &&
(nullish(rewardProgram.product_ids) ||
rewardProgram.product_ids!.length == 0)
) {
throw new RecaseError({
message: "If redeem on checkout, must specify at least one product",
code: ErrCode.InvalidRequest,
statusCode: 400,
});
}
let createdRewardProgram = await RewardProgramService.create({
db,
data: rewardProgram,
});
return res.status(200).json(createdRewardProgram);
},
}),
);
rewardProgramRouter.delete("/:id", (req, res) =>
routeHandler({
req,
res,
action: "delete reward scheme",
handler: async (req: any, res: any) => {
const { orgId, env, db } = req;
const { id } = req.params;
let rewardProgram = await RewardProgramService.delete({
db,
id,
orgId,
env,
});
return res.status(200).json(rewardProgram);
},
}),
);
rewardProgramRouter.delete("/:id", handleDeleteRewardProgram);

View File

@@ -1,239 +1,14 @@
import express from "express";
import { CreateRewardSchema, ErrCode, RewardCategory } from "@autumn/shared";
import { Router } from "express";
import RecaseError, { handleRequestError } from "@/utils/errorUtils.js";
import { createStripeCli } from "@/external/stripe/utils.js";
import { OrgService } from "@/internal/orgs/OrgService.js";
import { createStripeCoupon } from "@/external/stripe/stripeCouponUtils/stripeCouponUtils.js";
import { RewardService } from "@/internal/rewards/RewardService.js";
import { PriceService } from "@/internal/products/prices/PriceService.js";
import { EntitlementService } from "@/internal/products/entitlements/EntitlementService.js";
import {
constructReward,
getRewardCat,
initRewardStripePrices,
} from "@/internal/rewards/rewardUtils.js";
import { notNullish } from "@/utils/genUtils.js";
import express, { type Router } from "express";
import { handleCreateCoupon, handleDeleteCoupon, handleGetCoupon, handleUpdateCoupon } from "./handlers/rewards/index.js";
const rewardRouter: Router = express.Router();
rewardRouter.post("", async (req: any, res: any) => {
try {
const { db, orgId, env, logtail: logger } = req;
const rewardBody = req.body;
const rewardData = CreateRewardSchema.parse(rewardBody);
rewardRouter.post("", handleCreateCoupon);
const org = await OrgService.getFromReq(req);
rewardRouter.delete("/:id", handleDeleteCoupon);
const newReward = constructReward({
reward: rewardData,
orgId,
env,
// internalId: rewardBody.internal_id,
});
rewardRouter.post("/:internalId", handleUpdateCoupon);
if (getRewardCat(newReward) === RewardCategory.Discount) {
const stripeCli = createStripeCli({
org,
env,
});
rewardRouter.get("/:id", handleGetCoupon);
let discountConfig = newReward.discount_config;
// Get prices for coupon
const [prices, entitlements] = await Promise.all([
PriceService.getInIds({
db,
ids: discountConfig!.price_ids || [],
}),
EntitlementService.getByOrg({
db,
orgId,
env,
}),
]);
await initRewardStripePrices({
db,
prices,
org,
env,
logger,
});
await createStripeCoupon({
reward: newReward,
org,
env,
prices,
logger,
legacyVersion: req.query.legacyStripe === "true",
});
}
const insertedCoupon = await RewardService.insert({
db,
data: newReward,
});
res.status(200).json(insertedCoupon);
} catch (error) {
handleRequestError({
error,
res,
req,
action: "create coupon",
});
}
});
rewardRouter.delete("/:id", async (req: any, res: any) => {
try {
const { id } = req.params;
const { orgId, env, db } = req;
const org = await OrgService.getFromReq(req);
const stripeCli = createStripeCli({
org,
env,
});
let reward = await RewardService.get({
db,
idOrInternalId: id,
orgId,
env,
});
if (!reward) {
throw new RecaseError({
message: `Reward ${id} not found`,
code: ErrCode.InvalidRequest,
});
}
try {
await stripeCli.coupons.del(reward.id);
} catch (error: any) {
console.log(`Failed to delete coupon from stripe: ${error.message}`);
}
await RewardService.delete({
db,
internalId: reward.internal_id,
env,
orgId,
});
res.status(200).json({
success: true,
message: "Reward deleted successfully",
});
} catch (error) {
handleRequestError({
error,
res,
req,
action: "delete coupon",
});
}
});
rewardRouter.post("/:internalId", async (req: any, res: any) => {
try {
const { internalId } = req.params;
const { orgId, env, db, logtail: logger } = req;
const rewardBody = req.body;
const org = await OrgService.getFromReq(req);
const stripeCli = createStripeCli({
org,
env,
});
const reward = await RewardService.get({
db,
idOrInternalId: internalId,
orgId,
env,
});
if (!reward) {
throw new RecaseError({
message: `Reward ${internalId} not found`,
code: ErrCode.InvalidRequest,
});
}
const prices = await PriceService.getInIds({
db,
ids: notNullish(rewardBody.price_ids)
? rewardBody.price_ids
: reward.discount_config?.price_ids,
});
// 1. Delete old prices from stripe
try {
await stripeCli.coupons.del(reward.id);
await stripeCli.coupons.del(reward.internal_id);
} catch (error) {
// console.log(`Failed to delete coupon from stripe: ${error.message}`);
}
let rewardCat = getRewardCat(rewardBody);
if (rewardCat == RewardCategory.Discount) {
await createStripeCoupon({
reward: rewardBody,
org,
env,
prices,
logger,
legacyVersion: req.query.legacyStripe === "true",
});
}
// 3. Update coupon in db
const updatedCoupon = await RewardService.update({
db,
internalId: reward.internal_id,
env,
orgId,
update: rewardBody,
});
res.status(200).json(updatedCoupon);
} catch (error) {
handleRequestError({
error,
res,
req,
action: "update coupon",
});
}
});
rewardRouter.get("/:id", async (req: any, res: any) => {
try {
const { id } = req.params;
const { orgId, env, db } = req;
const reward = await RewardService.get({
db,
idOrInternalId: id,
orgId,
env,
});
res.status(200).json(reward);
} catch (error) {
handleRequestError({
error,
res,
req,
action: "get reward",
});
}
});
export default rewardRouter;
export default rewardRouter;

View File

@@ -1,41 +1,39 @@
import {
CusProductStatus,
Price,
ProcessorType,
CustomerEntitlement,
CusProduct,
FeatureOptions,
FreeTrial,
type APIVersion,
CollectionMethod,
FullCusProduct,
APIVersion,
InsertReplaceable,
ProductOptions,
type CusProduct,
CusProductStatus,
type Customer,
type CustomerEntitlement,
type CustomerPrice,
type FeatureOptions,
type FreeTrial,
type FullCusProduct,
type FullProduct,
type InsertReplaceable,
type Price,
ProcessorType,
type ProductOptions,
} from "@autumn/shared";
import { generateId, notNullish, nullish } from "@/utils/genUtils.js";
import { Customer } from "@autumn/shared";
import { FullProduct } from "@autumn/shared";
import { getEntOptions } from "@/internal/products/prices/priceUtils.js";
import { CustomerPrice } from "@autumn/shared";
import { CusProductService } from "../cusProducts/CusProductService.js";
import { InsertCusProductParams } from "../cusProducts/AttachParams.js";
import { freeTrialToStripeTimestamp } from "@/internal/products/free-trials/freeTrialUtils.js";
import { getEntRelatedPrice } from "@/internal/products/entitlements/entitlementUtils.js";
import { getExistingCusProducts } from "../cusProducts/cusProductUtils/getExistingCusProducts.js";
import { isFreeProduct, isOneOff } from "@/internal/products/productUtils.js";
import { searchCusProducts } from "@/internal/customers/cusProducts/cusProductUtils.js";
import { updateOneTimeCusProduct } from "./createOneTimeCusProduct.js";
import { initCusEntitlement } from "./initCusEnt.js";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import { addProductsUpdatedWebhookTask } from "@/internal/analytics/handlers/handleProductsUpdated.js";
import { DrizzleCli } from "@/db/initDrizzle.js";
import { searchCusProducts } from "@/internal/customers/cusProducts/cusProductUtils.js";
import { getEntRelatedPrice } from "@/internal/products/entitlements/entitlementUtils.js";
import { freeTrialToStripeTimestamp } from "@/internal/products/free-trials/freeTrialUtils.js";
import { getEntOptions } from "@/internal/products/prices/priceUtils.js";
import { isFreeProduct, isOneOff } from "@/internal/products/productUtils.js";
import { generateId, notNullish, nullish } from "@/utils/genUtils.js";
import type { InsertCusProductParams } from "../cusProducts/AttachParams.js";
import { CusProductService } from "../cusProducts/CusProductService.js";
import { CusEntService } from "../cusProducts/cusEnts/CusEntitlementService.js";
import { CusPriceService } from "../cusProducts/cusPrices/CusPriceService.js";
import { addExistingUsagesToCusEnts } from "../cusProducts/cusEnts/cusEntUtils/getExistingUsage.js";
import { RepService } from "../cusProducts/cusEnts/RepService.js";
import { getNewProductRollovers } from "../cusProducts/cusEnts/cusRollovers/getNewProductRollovers.js";
import { RolloverService } from "../cusProducts/cusEnts/cusRollovers/RolloverService.js";
import { RepService } from "../cusProducts/cusEnts/RepService.js";
import { CusPriceService } from "../cusProducts/cusPrices/CusPriceService.js";
import { getExistingCusProducts } from "../cusProducts/cusProductUtils/getExistingCusProducts.js";
import { updateOneTimeCusProduct } from "./createOneTimeCusProduct.js";
import { initCusEntitlement } from "./initCusEnt.js";
export const initCusPrice = ({
price,
@@ -103,7 +101,7 @@ export const initCusProduct = ({
apiVersion?: APIVersion;
quantity?: number;
}) => {
let isFuture = startsAt && startsAt > Date.now();
const isFuture = startsAt && startsAt > Date.now();
let trialEnds = trialEndsAt;
if (!trialEndsAt && freeTrial) {
@@ -197,7 +195,7 @@ export const expireOrDeleteCusProduct = async ({
}) => {
// 1. If startsAt
if (startsAt && startsAt > Date.now()) {
let curScheduledProduct = cusProducts?.find(
const curScheduledProduct = cusProducts?.find(
(cp) =>
cp.product.group === product.group &&
cp.status === CusProductStatus.Scheduled &&
@@ -213,7 +211,7 @@ export const expireOrDeleteCusProduct = async ({
});
}
} else {
let { curMainProduct } = getExistingCusProducts({
const { curMainProduct } = getExistingCusProducts({
product,
cusProducts: cusProducts as FullCusProduct[],
internalEntityId,
@@ -318,7 +316,7 @@ export const createFullCusProduct = async ({
attachParams;
// Try to get current cus product or set to null...
let curCusProduct = await getExistingCusProduct({
const curCusProduct = await getExistingCusProduct({
db,
cusProducts: attachParams.cusProducts,
product,
@@ -333,7 +331,7 @@ export const createFullCusProduct = async ({
trialEndsAt = curCusProduct.trial_ends_at || undefined;
}
let attachReplaceables = attachParams.replaceables || [];
const attachReplaceables = attachParams.replaceables || [];
const existingCusProduct = searchCusProducts({
internalProductId: product.internal_id,
@@ -388,7 +386,7 @@ export const createFullCusProduct = async ({
cusEnts.push(cusEnt);
let newReplaceables_ = attachReplaceables
const newReplaceables_ = attachReplaceables
.filter((r) => r.ent.id === entitlement.id)
.map((r) => ({
...r,
@@ -411,7 +409,7 @@ export const createFullCusProduct = async ({
});
// 4. Get new rollovers
let rolloverOps = await getNewProductRollovers({
const rolloverOps = await getNewProductRollovers({
db,
curCusProduct: curCusProduct as FullCusProduct,
cusEnts,
@@ -493,7 +491,7 @@ export const createFullCusProduct = async ({
replaceables: newReplaceables,
});
let rolloverInserts: any = [];
const rolloverInserts: any = [];
for (const operation of rolloverOps) {
rolloverInserts.push(
@@ -505,7 +503,7 @@ export const createFullCusProduct = async ({
);
}
let finalRollovers = (await Promise.all(rolloverInserts)).flatMap((r) => r);
const finalRollovers = (await Promise.all(rolloverInserts)).flatMap((r) => r);
// Get rollovers for each entitlement
const cusEntsWithRollovers = await Promise.all(
@@ -526,7 +524,7 @@ export const createFullCusProduct = async ({
}))
);
let fullCusProduct = {
const fullCusProduct = {
...cusProd,
product,
customer_entitlements: cusEntsWithRollovers,

View File

@@ -1,21 +1,22 @@
import { freeTrialToStripeTimestamp } from "@/internal/products/free-trials/freeTrialUtils.js";
import RecaseError from "@/utils/errorUtils.js";
import { ErrCode, Reward, IntervalConfig, AttachConfig } from "@autumn/shared";
import Stripe from "stripe";
import { type AttachConfig, ErrCode } from "@autumn/shared";
import type Stripe from "stripe";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import { getCusPaymentMethod } from "@/external/stripe/stripeCusUtils.js";
import { SubService } from "@/internal/subscriptions/SubService.js";
import { generateId } from "@/utils/genUtils.js";
import { DrizzleCli } from "@/db/initDrizzle.js";
import {
getLatestPeriodStart,
getEarliestPeriodEnd,
getLatestPeriodStart,
} from "@/external/stripe/stripeSubUtils/convertSubUtils.js";
import { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js";
import { sanitizeSubItems } from "@/external/stripe/stripeSubUtils/getStripeSubItems.js";
import { ItemSet } from "@/utils/models/ItemSet.js";
import type { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js";
import { buildInvoiceMemoFromEntitlements } from "@/internal/invoices/invoiceMemoUtils.js";
import {
freeTrialToStripeTimestamp,
rewardTrialToStripeTimestamp,
} from "@/internal/products/free-trials/freeTrialUtils.js";
import { SubService } from "@/internal/subscriptions/SubService.js";
import RecaseError from "@/utils/errorUtils.js";
import { generateId } from "@/utils/genUtils.js";
import type { ItemSet } from "@/utils/models/ItemSet.js";
// Get payment method
@@ -36,16 +37,17 @@ export const createStripeSub2 = async ({
itemSet: ItemSet;
logger: any;
}) => {
const { customer, invoiceOnly, freeTrial, org, now, rewards } = attachParams;
const isDefaultTrial = freeTrial && !freeTrial.card_required;
const { customer, invoiceOnly, freeTrial, org, now, rewards, rewardTrial } =
attachParams;
// const isDefaultTrial = freeTrial && !freeTrial.card_required;
// let shouldErrorIfNoPm = !invoiceOnly;
// if (isDefaultTrial) shouldErrorIfNoPm = false;
// if (rewardTrial) shouldErrorIfNoPm = false;
let shouldErrorIfNoPm = !invoiceOnly;
if (isDefaultTrial) shouldErrorIfNoPm = false;
let paymentMethod = await getCusPaymentMethod({
const paymentMethod = await getCusPaymentMethod({
stripeCli,
stripeId: customer.processor.id,
errorIfNone: shouldErrorIfNoPm,
errorIfNone: config.requirePaymentMethod,
});
let paymentMethodData = {};
@@ -68,7 +70,6 @@ export const createStripeSub2 = async ({
items: sanitizeSubItems(subItems),
billing_mode: { type: "flexible" },
trial_end: freeTrialToStripeTimestamp({ freeTrial, now }),
payment_behavior: "error_if_incomplete",
add_invoice_items: invoiceItems,
collection_method: invoiceOnly ? "send_invoice" : "charge_automatically",
@@ -80,14 +81,30 @@ export const createStripeSub2 = async ({
discounts,
expand: ["latest_invoice"],
trial_settings:
freeTrial && !freeTrial.card_required
? {
end_behavior: {
missing_payment_method: "cancel",
},
}
: undefined,
...{
trial_settings:
freeTrial && !freeTrial.card_required
? {
end_behavior: {
missing_payment_method: "cancel",
},
}
: undefined,
trial_end: freeTrialToStripeTimestamp({ freeTrial, now }),
},
// ...{
// trial_settings: rewardTrial
// ? {
// end_behavior: {
// missing_payment_method: "cancel",
// },
// }
// : undefined,
// trial_end: rewardTrialToStripeTimestamp({ rewardTrial, now }),
// },
});
const latestInvoice = subscription.latest_invoice as Stripe.Invoice;

View File

@@ -1,111 +1,114 @@
import { createFullCusProduct } from "../../../add-product/createFullCusProduct.js";
import {
AttachParams,
APIVersion,
AttachBranch,
type AttachConfig,
SuccessCode,
} from "@autumn/shared";
import { subToPeriodStartEnd } from "@/external/stripe/stripeSubUtils/convertSubUtils.js";
import { attachToInsertParams } from "@/internal/products/productUtils.js";
import type { ExtendedRequest } from "@/utils/models/Request.js";
import { createFullCusProduct } from "../../../add-product/createFullCusProduct.js";
import {
type AttachParams,
AttachResultSchema,
} from "../../../cusProducts/AttachParams.js";
import { APIVersion, AttachBranch, AttachConfig } from "@autumn/shared";
import { attachToInsertParams } from "@/internal/products/productUtils.js";
import { SuccessCode } from "@autumn/shared";
import { ExtendedRequest } from "@/utils/models/Request.js";
import { handlePaidProduct } from "./handlePaidProduct.js";
import { attachParamsToCurCusProduct } from "../../attachUtils/convertAttachParams.js";
import { getDefaultAttachConfig } from "../../attachUtils/getAttachConfig.js";
import { getMergeCusProduct } from "./getMergeCusProduct.js";
import { subToPeriodStartEnd } from "@/external/stripe/stripeSubUtils/convertSubUtils.js";
import { handlePaidProduct } from "./handlePaidProduct.js";
export const handleAddProduct = async ({
req,
res,
attachParams,
config,
branch,
req,
res,
attachParams,
config,
branch,
}: {
req: ExtendedRequest;
res?: any;
attachParams: AttachParams;
config?: AttachConfig;
branch?: AttachBranch;
req: ExtendedRequest;
res?: any;
attachParams: AttachParams;
config?: AttachConfig;
branch?: AttachBranch;
}) => {
const logger = req.logtail;
const { customer, products, prices } = attachParams;
const logger = req.logtail;
const { customer, products, prices } = attachParams;
const defaultConfig: AttachConfig = getDefaultAttachConfig();
const defaultConfig: AttachConfig = getDefaultAttachConfig();
// 1. If paid product
// 1. If paid product
if (prices.length > 0) {
await handlePaidProduct({
req,
res,
attachParams,
config: config || defaultConfig,
});
if (prices.length > 0) {
await handlePaidProduct({
req,
res,
attachParams,
config: config || defaultConfig,
});
return;
}
return;
}
logger.info("Inserting free product in handleAddProduct");
logger.info("Inserting free product in handleAddProduct");
const batchInsert = [];
const batchInsert = [];
const { mergeCusProduct, mergeSub } = await getMergeCusProduct({
attachParams,
config: config || defaultConfig,
products,
});
const { mergeSub } = await getMergeCusProduct({
attachParams,
config: config || defaultConfig,
products,
});
// console.log("Free trial:", attachParams.freeTrial);
// throw new Error("test");
// console.log("Free trial:", attachParams.freeTrial);
// throw new Error("test");
for (const product of products) {
let curCusProduct = attachParamsToCurCusProduct({ attachParams });
let anchorToUnix = undefined;
for (const product of products) {
const curCusProduct = attachParamsToCurCusProduct({ attachParams });
let anchorToUnix;
if (curCusProduct && config?.branch == AttachBranch.NewVersion) {
anchorToUnix = curCusProduct.created_at;
}
if (curCusProduct && config?.branch === AttachBranch.NewVersion) {
anchorToUnix = curCusProduct.created_at;
}
if (mergeSub) {
const { end } = subToPeriodStartEnd({ sub: mergeSub });
anchorToUnix = end * 1000;
}
if (mergeSub) {
const { end } = subToPeriodStartEnd({ sub: mergeSub });
anchorToUnix = end * 1000;
}
// Expire previous product
// Expire previous product
batchInsert.push(
createFullCusProduct({
db: req.db,
attachParams: attachToInsertParams(attachParams, product),
billLaterOnly: true,
carryExistingUsages: config?.carryUsage || false,
anchorToUnix,
logger,
})
);
}
await Promise.all(batchInsert);
batchInsert.push(
createFullCusProduct({
db: req.db,
attachParams: attachToInsertParams(attachParams, product),
billLaterOnly: true,
carryExistingUsages: config?.carryUsage || false,
anchorToUnix,
logger,
}),
);
}
await Promise.all(batchInsert);
logger.info("Successfully created full cus product");
logger.info("Successfully created full cus product");
if (res) {
let apiVersion = attachParams.org.api_version || APIVersion.v1;
const productNames = products.map((p) => p.name).join(", ");
const customerName = customer.name || customer.email || customer.id;
if (apiVersion >= APIVersion.v1_1) {
res.status(200).json(
AttachResultSchema.parse({
success: true,
code: SuccessCode.FreeProductAttached,
message: `Successfully attached ${productNames} to ${customerName}`,
product_ids: products.map((p) => p.id),
customer_id: customer.id || customer.internal_id,
})
);
} else {
res.status(200).json({
success: true,
});
}
}
if (res) {
const apiVersion = attachParams.org.api_version || APIVersion.v1;
const productNames = products.map((p) => p.name).join(", ");
const customerName = customer.name || customer.email || customer.id;
if (apiVersion >= APIVersion.v1_1) {
res.status(200).json(
AttachResultSchema.parse({
success: true,
code: SuccessCode.FreeProductAttached,
message: `Successfully attached ${productNames} to ${customerName}`,
product_ids: products.map((p) => p.id),
customer_id: customer.id || customer.internal_id,
}),
);
} else {
res.status(200).json({
success: true,
});
}
}
};

View File

@@ -1,9 +1,18 @@
import RecaseError from "@/utils/errorUtils.js";
import {
APIVersion,
type AttachConfig,
AttachScenario,
ErrCode,
isTrialing,
SuccessCode,
} from "@autumn/shared";
import type Stripe from "stripe";
import { getEarliestPeriodEnd } from "@/external/stripe/stripeSubUtils/convertSubUtils.js";
import { getStripeSubItems2 } from "@/external/stripe/stripeSubUtils/getStripeSubItems.js";
import { createFullCusProduct } from "@/internal/customers/add-product/createFullCusProduct.js";
import { handleCreateCheckout } from "@/internal/customers/add-product/handleCreateCheckout.js";
import {
AttachParams,
type AttachParams,
AttachResultSchema,
} from "@/internal/customers/cusProducts/AttachParams.js";
import {
@@ -12,23 +21,9 @@ import {
} from "@/internal/invoices/invoiceUtils.js";
import { attachToInsertParams } from "@/internal/products/productUtils.js";
import { ExtendedRequest } from "@/utils/models/Request.js";
import {
APIVersion,
AttachBranch,
AttachConfig,
AttachScenario,
ErrCode,
SuccessCode,
} from "@autumn/shared";
import Stripe from "stripe";
import {
getEarliestPeriodEnd,
subToPeriodStartEnd,
} from "@/external/stripe/stripeSubUtils/convertSubUtils.js";
import { createStripeSub2 } from "./createStripeSub2.js";
import { getSmallestInterval } from "@/internal/products/prices/priceUtils/priceIntervalUtils.js";
import { handleCreateCheckout } from "@/internal/customers/add-product/handleCreateCheckout.js";
import {
getCustomerSchedule,
getCustomerSub,
@@ -37,11 +32,12 @@ import {
import { paramsToSubItems } from "../../mergeUtils/paramsToSubItems.js";
import { updateStripeSub2 } from "../upgradeFlow/updateStripeSub2.js";
import { subToNewSchedule } from "../../mergeUtils/subToNewSchedule.js";
import { isTrialing } from "@autumn/shared";
import { getNextStartOfMonthUnix } from "@/internal/products/prices/billingIntervalUtils.js";
import { addIntervalToAnchor } from "@/internal/products/prices/billingIntervalUtils2.js";
import { handleUpgradeFlowSchedule } from "../upgradeFlow/handleUpgradeFlowSchedule.js";
import { subIsCanceled } from "@/external/stripe/stripeSubUtils.js";
import { rewardTrialToStripeTimestamp } from "@/internal/products/free-trials/freeTrialUtils.js";
import RecaseError from "@/utils/errorUtils.js";
export const handlePaidProduct = async ({
req,
@@ -56,7 +52,7 @@ export const handlePaidProduct = async ({
}) => {
const logger = req.logtail;
let {
const {
org,
customer,
products,
@@ -75,7 +71,7 @@ export const handlePaidProduct = async ({
config,
});
let subscriptions: Stripe.Subscription[] = [];
const subscriptions: Stripe.Subscription[] = [];
const { sub: mergeSub, cusProduct: mergeCusProduct } = await getCustomerSub({
attachParams,
@@ -248,7 +244,14 @@ export const handlePaidProduct = async ({
anchorToUnix,
carryExistingUsages: config.carryUsage,
scenario: AttachScenario.New,
trialEndsAt: trialEndsAt || undefined,
trialEndsAt:
trialEndsAt ||
(attachParams.rewardTrial
? (rewardTrialToStripeTimestamp({
rewardTrial: attachParams.rewardTrial,
now: attachParams.now,
}) || 0) * 1000
: undefined),
logger,
})
);
@@ -256,7 +259,7 @@ export const handlePaidProduct = async ({
await Promise.all(batchInsert);
if (res) {
let apiVersion = attachParams.apiVersion || APIVersion.v1;
const apiVersion = attachParams.apiVersion || APIVersion.v1;
const productNames = products.map((p) => p.name).join(", ");
const customerName = customer.name || customer.email || customer.id;
if (apiVersion >= APIVersion.v1_1) {

View File

@@ -1,155 +1,163 @@
import Stripe from "stripe";
import { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js";
import { AttachConfig, ProrationBehavior } from "@autumn/shared";
import { attachParamToCusProducts } from "../../attachUtils/convertAttachParams.js";
import {
createUsageInvoiceItems,
resetUsageBalances,
} from "../upgradeDiffIntFlow/createUsageInvoiceItems.js";
import { ExtendedRequest } from "@/utils/models/Request.js";
import { createProrationInvoice } from "@/external/stripe/stripeSubUtils/updateStripeSub/createProrationinvoice.js";
import { createAndFilterContUseItems } from "../../attachUtils/getContUseItems/createContUseInvoiceItems.js";
import { freeTrialToStripeTimestamp } from "@/internal/products/free-trials/freeTrialUtils.js";
import { getContUseInvoiceItems } from "../../attachUtils/getContUseItems/getContUseInvoiceItems.js";
import { ItemSet } from "@/utils/models/ItemSet.js";
import { type AttachConfig, ProrationBehavior } from "@autumn/shared";
import type Stripe from "stripe";
import { sanitizeSubItems } from "@/external/stripe/stripeSubUtils/getStripeSubItems.js";
import { createProrationInvoice } from "@/external/stripe/stripeSubUtils/updateStripeSub/createProrationinvoice.js";
import type { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js";
import {
freeTrialToStripeTimestamp,
rewardTrialToStripeTimestamp,
} from "@/internal/products/free-trials/freeTrialUtils.js";
import { SubService } from "@/internal/subscriptions/SubService.js";
import { createStripeCli } from "@/external/stripe/utils.js";
import { nullish } from "@/utils/genUtils.js";
import RecaseError from "@/utils/errorUtils.js";
import { nullish } from "@/utils/genUtils.js";
import type { ItemSet } from "@/utils/models/ItemSet.js";
import type { ExtendedRequest } from "@/utils/models/Request.js";
import { attachParamToCusProducts } from "../../attachUtils/convertAttachParams.js";
import { createAndFilterContUseItems } from "../../attachUtils/getContUseItems/createContUseInvoiceItems.js";
import {
createUsageInvoiceItems,
resetUsageBalances,
} from "../upgradeDiffIntFlow/createUsageInvoiceItems.js";
export const updateStripeSub2 = async ({
req,
attachParams,
config,
curSub,
itemSet,
fromCreate = false,
req,
attachParams,
config,
curSub,
itemSet,
fromCreate = false,
}: {
req: ExtendedRequest;
attachParams: AttachParams;
config: AttachConfig;
curSub: Stripe.Subscription;
itemSet: ItemSet;
fromCreate?: boolean;
req: ExtendedRequest;
attachParams: AttachParams;
config: AttachConfig;
curSub: Stripe.Subscription;
itemSet: ItemSet;
fromCreate?: boolean;
}) => {
const { db, logger } = req;
const { db, logger } = req;
const { stripeCli, customer, org, paymentMethod } = attachParams;
const { invoiceOnly, proration } = config;
const { stripeCli, customer, org, paymentMethod, rewardTrial } = attachParams;
const { invoiceOnly, proration } = config;
if (!invoiceOnly && !attachParams.fromCancel && nullish(paymentMethod)) {
throw new RecaseError({
message: "Payment method is required",
code: "payment_method_required",
});
}
if (
!invoiceOnly &&
!attachParams.fromCancel &&
!rewardTrial &&
nullish(paymentMethod)
) {
throw new RecaseError({
message: "Payment method is required",
code: "payment_method_required",
});
}
if (curSub.billing_mode.type !== "flexible") {
curSub = await stripeCli.subscriptions.migrate(curSub.id, {
billing_mode: { type: "flexible" },
});
}
if (curSub.billing_mode.type !== "flexible") {
curSub = await stripeCli.subscriptions.migrate(curSub.id, {
billing_mode: { type: "flexible" },
});
}
let trialEnd =
config.disableTrial || config.carryTrial
? undefined
: freeTrialToStripeTimestamp({
freeTrial: attachParams.freeTrial,
now: attachParams.now,
});
const trialEnd =
config.disableTrial || config.carryTrial
? undefined
: rewardTrial?.duration_value
? rewardTrialToStripeTimestamp({ rewardTrial, now: attachParams.now })
: freeTrialToStripeTimestamp({
freeTrial: attachParams.freeTrial,
now: attachParams.now,
});
// 1. Update subscription
// 1. Update subscription
let updatedSub = await stripeCli.subscriptions.update(curSub.id, {
items: sanitizeSubItems(itemSet.subItems),
proration_behavior:
proration == ProrationBehavior.None
? "none"
: fromCreate
? "always_invoice"
: "create_prorations",
// proration_behavior: "create_prorations",
trial_end: trialEnd,
// default_payment_method: paymentMethod?.id,
add_invoice_items: itemSet.invoiceItems,
...((invoiceOnly && {
collection_method: "send_invoice",
days_until_due: 30,
}) as any),
payment_behavior: "error_if_incomplete",
expand: ["latest_invoice"],
});
const updatedSub = await stripeCli.subscriptions.update(curSub.id, {
items: sanitizeSubItems(itemSet.subItems),
proration_behavior:
proration === ProrationBehavior.None
? "none"
: fromCreate
? "always_invoice"
: "create_prorations",
// proration_behavior: "create_prorations",
trial_end: trialEnd,
// default_payment_method: paymentMethod?.id,
add_invoice_items: itemSet.invoiceItems,
...((invoiceOnly && {
collection_method: "send_invoice",
days_until_due: 30,
}) as any),
payment_behavior: "error_if_incomplete",
expand: ["latest_invoice"],
});
let latestInvoice = updatedSub.latest_invoice as Stripe.Invoice | null;
let latestInvoice = updatedSub.latest_invoice as Stripe.Invoice | null;
await SubService.updateFromStripe({ db, stripeSub: updatedSub });
await SubService.updateFromStripe({ db, stripeSub: updatedSub });
if (proration == ProrationBehavior.None) {
return {
updatedSub,
latestInvoice: null,
};
}
if (proration === ProrationBehavior.None) {
return {
updatedSub,
latestInvoice: null,
};
}
if (fromCreate) {
return {
updatedSub,
latestInvoice: updatedSub.latest_invoice as Stripe.Invoice,
};
}
if (fromCreate) {
return {
updatedSub,
latestInvoice: updatedSub.latest_invoice as Stripe.Invoice,
};
}
const { curMainProduct } = attachParamToCusProducts({ attachParams });
const { curMainProduct } = attachParamToCusProducts({ attachParams });
// 2. Create prorations for single use items
let { invoiceItems, cusEntIds } = await createUsageInvoiceItems({
db,
attachParams,
cusProduct: curMainProduct!,
sub: curSub,
logger,
});
// 2. Create prorations for single use items
const { invoiceItems, cusEntIds } = await createUsageInvoiceItems({
db,
attachParams,
cusProduct: curMainProduct!,
sub: curSub,
logger,
});
// // // 3. Create prorations for continuous use items
// let { replaceables, newItems } = await getContUseInvoiceItems({
// attachParams,
// cusProduct: curMainProduct!,
// sub: curSub,
// logger,
// });
// // // 3. Create prorations for continuous use items
// let { replaceables, newItems } = await getContUseInvoiceItems({
// attachParams,
// cusProduct: curMainProduct!,
// sub: curSub,
// logger,
// });
const { replaceables } = await createAndFilterContUseItems({
attachParams,
curMainProduct: curMainProduct!,
sub: curSub,
logger,
});
const { replaceables } = await createAndFilterContUseItems({
attachParams,
curMainProduct: curMainProduct!,
sub: curSub,
logger,
});
if (proration === ProrationBehavior.Immediately) {
latestInvoice = await createProrationInvoice({
attachParams,
invoiceOnly,
curSub,
updatedSub,
logger,
});
if (proration === ProrationBehavior.Immediately) {
latestInvoice = await createProrationInvoice({
attachParams,
invoiceOnly,
curSub,
updatedSub,
logger,
});
console.log(`FINALIZED INVOICE ${latestInvoice?.id}`);
console.log(latestInvoice?.lines.data.map((line) => line.description));
}
console.log(`FINALIZED INVOICE ${latestInvoice?.id}`);
console.log(latestInvoice?.lines.data.map((line) => line.description));
}
await resetUsageBalances({
db,
cusEntIds,
cusProduct: curMainProduct!,
});
await resetUsageBalances({
db,
cusEntIds,
cusProduct: curMainProduct!,
});
return {
updatedSub,
latestInvoice: latestInvoice,
cusEntIds,
replaceables,
};
return {
updatedSub,
latestInvoice: latestInvoice,
cusEntIds,
replaceables,
};
};
// await SubService.addUsageFeatures({

View File

@@ -1,23 +1,29 @@
import {
AttachParams,
InsertCusProductParams,
} from "@/internal/customers/cusProducts/AttachParams.js";
import {
type Customer,
cusProductToEnts,
cusProductToPrices,
cusProductToProduct,
type Entity,
type FreeTrial,
type FullCusProduct,
type FullCustomer,
type FullProduct,
type FullRewardProgram,
type Organization,
} from "@autumn/shared";
import type Stripe from "stripe";
import { createStripeCli } from "@/external/stripe/utils.js";
import type {
AttachParams,
InsertCusProductParams,
} from "@/internal/customers/cusProducts/AttachParams.js";
import { newCusToFullCus } from "@/internal/customers/cusUtils/cusUtils.js";
import { ExtendedRequest } from "@/utils/models/Request.js";
import {
Customer,
Entity,
FullCusProduct,
FullCustomer,
FullProduct,
FreeTrial,
} from "@autumn/shared";
import Stripe from "stripe";
isFreeProduct,
isOneOff,
itemsAreOneOff,
} from "@/internal/products/productUtils.js";
import type { ExtendedRequest } from "@/utils/models/Request.js";
export const webhookToAttachParams = ({
req,
@@ -156,3 +162,44 @@ export const newCusToInsertParams = ({
features: [],
} satisfies InsertCusProductParams;
};
export const rewardProgramToAttachParams = ({
req,
rewardProgram,
customer,
product,
org,
}: {
req: ExtendedRequest;
rewardProgram: FullRewardProgram;
customer: FullCustomer;
product: FullProduct;
org?: Organization;
}): AttachParams => {
const reward = rewardProgram.reward;
const isPaid = !isFreeProduct(product.prices);
const isRecurring =
!isOneOff(product.prices) && !itemsAreOneOff(product.entitlements);
return {
req,
org: org || req.org,
customer,
products: [product],
prices: product.prices,
entitlements: product.entitlements,
freeTrial: null,
// rewardTrial:
// (isPaid && isRecurring && reward.free_product_config)
// ? reward.free_product_config
// : null,
rewards: [reward],
optionsList: [],
cusProducts: customer.customer_products,
entities: [],
features: req.features,
stripeCli: createStripeCli({ org: org || req.org, env: req.env }),
paymentMethod: null,
replaceables: [],
} satisfies AttachParams;
};

View File

@@ -124,9 +124,21 @@ export const getAttachConfig = async ({
branch != AttachBranch.MultiAttachUpdate);
const onlyCheckout = !isFree && checkoutFlow && !freeTrialWithoutCardRequired;
const disableMerge = branch == AttachBranch.MainIsTrial || onlyCheckout;
// Require payment method...
let paymentMethodRequired = true;
if (
!disableTrial &&
attachParams.freeTrial &&
attachParams.freeTrial.card_required === false
) {
paymentMethodRequired = false;
}
if (attachParams.invoiceOnly) {
paymentMethodRequired = false;
}
let config: AttachConfig = {
branch,
onlyCheckout,
@@ -141,6 +153,7 @@ export const getAttachConfig = async ({
finalizeInvoice: notNullish(attachBody.finalize_invoice)
? attachBody.finalize_invoice!
: true,
requirePaymentMethod: paymentMethodRequired,
};
return { flags, config };
@@ -159,6 +172,7 @@ export const getDefaultAttachConfig = () => {
carryTrial: false,
invoiceCheckout: false,
finalizeInvoice: true,
requirePaymentMethod: true,
};
return config;

View File

@@ -1,23 +1,26 @@
import {
type AttachBody,
AttachBranch,
type AttachConfig,
AttachFunction,
CusProductStatus,
} from "@autumn/shared";
import chalk from "chalk";
import { AttachParams } from "../../cusProducts/AttachParams.js";
import { AttachBranch, AttachFunction, CusProductStatus } from "@autumn/shared";
import { handleCreateCheckout } from "../../add-product/handleCreateCheckout.js";
import { handleCreateInvoiceCheckout } from "../../add-product/handleCreateInvoiceCheckout.js";
import type { AttachParams } from "../../cusProducts/AttachParams.js";
import { CusProductService } from "../../cusProducts/CusProductService.js";
import { handleAddProduct } from "../attachFunctions/addProductFlow/handleAddProduct.js";
import { AttachBody } from "@autumn/shared";
import { AttachConfig } from "@autumn/shared";
import { handleOneOffFunction } from "../attachFunctions/addProductFlow/handleOneOffFunction.js";
import { handleRenewProduct } from "../attachFunctions/handleRenewProduct.js";
import { handleMultiAttachFlow } from "../attachFunctions/multiAttach/handleMultiAttachFlow.js";
import { handleScheduleFunction2 } from "../attachFunctions/scheduleFlow/handleScheduleFlow2.js";
import { handleUpdateQuantityFunction } from "../attachFunctions/updateQuantityFlow/updateQuantityFlow.js";
import { handleUpgradeFlow } from "../attachFunctions/upgradeFlow/handleUpgradeFlow.js";
import {
attachParamsToCurCusProduct,
attachParamToCusProducts,
} from "./convertAttachParams.js";
import { handleOneOffFunction } from "../attachFunctions/addProductFlow/handleOneOffFunction.js";
import { CusProductService } from "../../cusProducts/CusProductService.js";
import { handleCreateInvoiceCheckout } from "../../add-product/handleCreateInvoiceCheckout.js";
import { handleUpgradeFlow } from "../attachFunctions/upgradeFlow/handleUpgradeFlow.js";
import { handleScheduleFunction2 } from "../attachFunctions/scheduleFlow/handleScheduleFlow2.js";
import { handleRenewProduct } from "../attachFunctions/handleRenewProduct.js";
import { handleMultiAttachFlow } from "../attachFunctions/multiAttach/handleMultiAttachFlow.js";
/*
1. If from new version, free trial should just carry over
@@ -27,252 +30,252 @@ import { handleMultiAttachFlow } from "../attachFunctions/multiAttach/handleMult
*/
export const getAttachFunction = async ({
branch,
attachParams,
attachBody,
config,
branch,
attachParams,
attachBody,
config,
}: {
branch: AttachBranch;
attachParams: AttachParams;
attachBody: AttachBody;
config: AttachConfig;
branch: AttachBranch;
attachParams: AttachParams;
attachBody: AttachBody;
config: AttachConfig;
}) => {
const { onlyCheckout } = config;
const { curCusProduct } = attachParamToCusProducts({
attachParams,
});
const { onlyCheckout } = config;
const { curCusProduct } = attachParamToCusProducts({
attachParams,
});
// 1. Checkout function
const newScenario = [
AttachBranch.MultiAttach,
AttachBranch.MultiProduct,
AttachBranch.OneOff,
AttachBranch.New,
AttachBranch.AddOn,
AttachBranch.MainIsFree,
AttachBranch.MainIsTrial,
].includes(branch);
// 1. Checkout function
const newScenario = [
AttachBranch.MultiAttach,
AttachBranch.MultiProduct,
AttachBranch.OneOff,
AttachBranch.New,
AttachBranch.AddOn,
AttachBranch.MainIsFree,
AttachBranch.MainIsTrial,
].includes(branch);
if (newScenario && onlyCheckout) {
return AttachFunction.CreateCheckout;
} else if (branch == AttachBranch.OneOff) {
return AttachFunction.OneOff;
} else if (
branch == AttachBranch.MultiAttach ||
branch == AttachBranch.MultiAttachUpdate
) {
return AttachFunction.MultiAttach;
} else if (newScenario) {
return AttachFunction.AddProduct;
}
if (newScenario && onlyCheckout) {
return AttachFunction.CreateCheckout;
} else if (branch == AttachBranch.OneOff) {
return AttachFunction.OneOff;
} else if (
branch == AttachBranch.MultiAttach ||
branch == AttachBranch.MultiAttachUpdate
) {
return AttachFunction.MultiAttach;
} else if (newScenario) {
return AttachFunction.AddProduct;
}
// 2. Upgrade scenarios
let updateScenarios = [
AttachBranch.NewVersion,
AttachBranch.SameCustom,
AttachBranch.SameCustomEnts,
AttachBranch.Upgrade,
];
// 2. Upgrade scenarios
const updateScenarios = [
AttachBranch.NewVersion,
AttachBranch.SameCustom,
AttachBranch.SameCustomEnts,
AttachBranch.Upgrade,
];
if (updateScenarios.includes(branch)) {
if (config.sameIntervals) {
return AttachFunction.UpgradeSameInterval;
} else {
return AttachFunction.UpgradeDiffInterval;
}
}
if (updateScenarios.includes(branch)) {
if (config.sameIntervals) {
return AttachFunction.UpgradeSameInterval;
} else {
return AttachFunction.UpgradeDiffInterval;
}
}
// 3. Downgrade scenarios
if (branch == AttachBranch.Downgrade) {
return AttachFunction.ScheduleProduct;
}
// 3. Downgrade scenarios
if (branch == AttachBranch.Downgrade) {
return AttachFunction.ScheduleProduct;
}
// 4. Prepaid scenarios
if (branch == AttachBranch.UpdatePrepaidQuantity) {
let curSameProduct = attachParamsToCurCusProduct({ attachParams });
if (curSameProduct?.free_trial) {
attachParams.freeTrial = curSameProduct.free_trial;
}
return AttachFunction.UpdatePrepaidQuantity;
}
// 4. Prepaid scenarios
if (branch == AttachBranch.UpdatePrepaidQuantity) {
const curSameProduct = attachParamsToCurCusProduct({ attachParams });
if (curSameProduct?.free_trial) {
attachParams.freeTrial = curSameProduct.free_trial;
}
return AttachFunction.UpdatePrepaidQuantity;
}
if (branch == AttachBranch.Renew) {
return AttachFunction.Renew;
}
if (branch == AttachBranch.Renew) {
return AttachFunction.Renew;
}
return AttachFunction.AddProduct;
return AttachFunction.AddProduct;
};
export const runAttachFunction = async ({
req,
res,
branch,
attachParams,
attachBody,
config,
req,
res,
branch,
attachParams,
attachBody,
config,
}: {
req: any;
res: any;
branch: AttachBranch;
attachParams: AttachParams;
attachBody: AttachBody;
config: AttachConfig;
req: any;
res: any;
branch: AttachBranch;
attachParams: AttachParams;
attachBody: AttachBody;
config: AttachConfig;
}) => {
const { logtail: logger, db } = req;
const { stripeCli } = attachParams;
const { logtail: logger, db } = req;
const { stripeCli } = attachParams;
const attachFunction = await getAttachFunction({
branch,
attachParams,
attachBody,
config,
});
const attachFunction = await getAttachFunction({
branch,
attachParams,
attachBody,
config,
});
// console.log("Attach Function:", attachFunction);
// throw new Error("Attach Function:");
// console.log("Attach Function:", attachFunction);
// throw new Error("Attach Function:");
const customer = attachParams.customer;
const org = attachParams.org;
const customer = attachParams.customer;
const org = attachParams.org;
const productIdsStr = attachParams.products.map((p) => p.id).join(", ");
const { curMainProduct, curSameProduct, curScheduledProduct } =
attachParamToCusProducts({
attachParams,
});
const productIdsStr = attachParams.products.map((p) => p.id).join(", ");
const { curMainProduct, curSameProduct, curScheduledProduct } =
attachParamToCusProducts({
attachParams,
});
const curCusProduct = attachParamsToCurCusProduct({ attachParams });
const curCusProduct = attachParamsToCurCusProduct({ attachParams });
logger.info(`--------------------------------`);
logger.info(
`ATTACHING ${productIdsStr} to ${customer.name} (${customer.id || customer.email}), org: ${org.slug}\n`
);
if (customer.entity) {
logger.info(`Entity: ${customer.entity.name} (${customer.entity.id})`);
}
logger.info(
`Branch: ${chalk.yellow(branch)}, Function: ${chalk.yellow(attachFunction)}`
);
logger.info(`--------------------------------`);
logger.info(
`ATTACHING ${productIdsStr} to ${customer.name} (${customer.id || customer.email}), org: ${org.slug}\n`,
);
if (customer.entity) {
logger.info(`Entity: ${customer.entity.name} (${customer.entity.id})`);
}
logger.info(
`Branch: ${chalk.yellow(branch)}, Function: ${chalk.yellow(attachFunction)}`,
);
if (curMainProduct) {
logger.info(`→ Current Main Product: ${curMainProduct.product.id}`);
}
if (curSameProduct) {
logger.info(`→ Current Same Product: ${curSameProduct.product.id}`);
}
if (curScheduledProduct) {
logger.info(
`→ Current Scheduled Product: ${curScheduledProduct.product.id}`
);
}
if (curMainProduct) {
logger.info(`→ Current Main Product: ${curMainProduct.product.id}`);
}
if (curSameProduct) {
logger.info(`→ Current Same Product: ${curSameProduct.product.id}`);
}
if (curScheduledProduct) {
logger.info(
`→ Current Scheduled Product: ${curScheduledProduct.product.id}`,
);
}
if (attachFunction == AttachFunction.OneOff) {
return await handleOneOffFunction({
req,
res,
attachParams,
config,
});
}
if (attachFunction === AttachFunction.OneOff) {
return await handleOneOffFunction({
req,
res,
attachParams,
config,
});
}
if (attachFunction == AttachFunction.Renew) {
return await handleRenewProduct({
req,
res,
attachParams,
config,
});
}
if (attachFunction === AttachFunction.Renew) {
return await handleRenewProduct({
req,
res,
attachParams,
config,
});
}
// 2. If main is trial, cancel it...
if (branch == AttachBranch.MainIsTrial) {
await CusProductService.update({
db,
cusProductId: curMainProduct!.id,
updates: {
ended_at: attachParams.now,
canceled: true,
status: CusProductStatus.Expired,
},
});
// 2. If main is trial, cancel it...
if (branch === AttachBranch.MainIsTrial) {
await CusProductService.update({
db,
cusProductId: curMainProduct!.id,
updates: {
ended_at: attachParams.now,
canceled: true,
status: CusProductStatus.Expired,
},
});
const subId = curMainProduct?.subscription_ids?.[0];
if (subId) {
await stripeCli.subscriptions.cancel(subId, {
cancellation_details: {
comment: "autumn_downgrade,trial_canceled",
},
});
}
}
const subId = curMainProduct?.subscription_ids?.[0];
if (subId) {
await stripeCli.subscriptions.cancel(subId, {
cancellation_details: {
comment: "autumn_downgrade,trial_canceled",
},
});
}
}
if (attachFunction == AttachFunction.MultiAttach) {
return await handleMultiAttachFlow({
req,
res,
attachParams,
attachBody,
branch,
config,
});
}
if (attachFunction === AttachFunction.MultiAttach) {
return await handleMultiAttachFlow({
req,
res,
attachParams,
attachBody,
branch,
config,
});
}
if (attachFunction == AttachFunction.CreateCheckout) {
if (config.invoiceCheckout) {
return await handleCreateInvoiceCheckout({
req,
res,
attachParams,
attachBody,
config,
branch,
});
}
return await handleCreateCheckout({
req,
res,
attachParams,
config,
});
}
if (attachFunction === AttachFunction.CreateCheckout) {
if (config.invoiceCheckout) {
return await handleCreateInvoiceCheckout({
req,
res,
attachParams,
attachBody,
config,
branch,
});
}
return await handleCreateCheckout({
req,
res,
attachParams,
config,
});
}
if (attachFunction == AttachFunction.AddProduct) {
return await handleAddProduct({
req,
res,
attachParams,
config,
branch,
});
}
if (attachFunction === AttachFunction.AddProduct) {
return await handleAddProduct({
req,
res,
attachParams,
config,
branch,
});
}
if (attachFunction == AttachFunction.ScheduleProduct) {
return await handleScheduleFunction2({
req,
res,
attachParams,
config,
});
}
if (attachFunction === AttachFunction.ScheduleProduct) {
return await handleScheduleFunction2({
req,
res,
attachParams,
config,
});
}
if (
attachFunction == AttachFunction.UpgradeDiffInterval ||
attachFunction == AttachFunction.UpgradeSameInterval
) {
return await handleUpgradeFlow({
req,
res,
attachParams,
config,
branch,
});
}
if (
attachFunction === AttachFunction.UpgradeDiffInterval ||
attachFunction === AttachFunction.UpgradeSameInterval
) {
return await handleUpgradeFlow({
req,
res,
attachParams,
config,
branch,
});
}
if (attachFunction == AttachFunction.UpdatePrepaidQuantity) {
return await handleUpdateQuantityFunction({
req,
res,
attachParams,
config,
});
}
if (attachFunction === AttachFunction.UpdatePrepaidQuantity) {
return await handleUpdateQuantityFunction({
req,
res,
attachParams,
config,
});
}
};

View File

@@ -1,22 +1,23 @@
import {
import type {
APIVersion,
AttachReplaceable,
AttachScenario,
Customer,
EntitlementWithFeature,
Entity,
FeatureOptions,
Feature,
FeatureOptions,
FreeProductConfig,
FreeTrial,
FullCusProduct,
FullCustomer,
FullProduct,
Organization,
Price,
AttachScenario,
APIVersion,
FullCustomer,
AttachReplaceable,
Reward,
ProductOptions,
Reward,
} from "@autumn/shared";
import Stripe from "stripe";
import type Stripe from "stripe";
import { z } from "zod";
@@ -39,6 +40,7 @@ export type AttachParams = {
entitlements: EntitlementWithFeature[];
freeTrial: FreeTrial | null;
rewardTrial?: FreeProductConfig | null;
optionsList: FeatureOptions[];
productsList?: ProductOptions[];

View File

@@ -221,7 +221,6 @@ export const addExistingUsagesToCusEnts = ({
for (const cusEnt of fullCusEnts) {
let ent = cusEnt.entitlement;
let fromEntities = existingUsages[key].fromEntities;
// if (cusEntKey !== key) continue;

View File

@@ -1,11 +1,5 @@
import { CusService } from "@/internal/customers/CusService.js";
import { routeHandler } from "@/utils/routerUtils.js";
import {
APIVersion,
CusExpand,
CusProductStatus,
ErrCode,
} from "@autumn/shared";
import { APIVersion, CusExpand, ErrCode } from "@autumn/shared";
import { StatusCodes } from "http-status-codes";
import { getCustomerDetails } from "../cusUtils/getCustomerDetails.js";
import { parseCusExpand } from "../cusUtils/cusUtils.js";
@@ -30,9 +24,7 @@ export const handleGetCustomer = async (req: any, res: any) =>
});
let getInvoices = apiVersion < APIVersion.v1_1;
if (getInvoices) {
expandArray.push(CusExpand.Invoices);
}
if (getInvoices) expandArray.push(CusExpand.Invoices);
logger.info(`getting customer ${customerId} for org ${org.slug}`);
const startTime = Date.now();
@@ -46,16 +38,6 @@ export const handleGetCustomer = async (req: any, res: any) =>
logger,
});
// const customer = await CusService.getFull({
// db,
// idOrInternalId: customerId,
// orgId: org.id,
// env: env,
// withEntities: true,
// expand: expandArray,
// allowNotFound: true,
// withSubs: true,
// });
logger.info(`get customer took ${Date.now() - startTime}ms`);
if (!customer) {

View File

@@ -1,130 +1,124 @@
import {
Organization,
Feature,
FullProduct,
EntitlementWithFeature,
AllowanceType,
getFeatureName,
FeatureType,
Price,
type EntitlementWithFeature,
type Feature, type FullProduct, type Organization,
type Price
} from "@autumn/shared";
import { mapToProductItems } from "@/internal/products/productV2Utils.js";
import { getProductItemDisplay } from "../products/productUtils/productResponseUtils/getProductItemDisplay.js";
import { getEntRelatedPrice } from "../products/entitlements/entitlementUtils.js";
import { isFeatureItem } from "../products/product-items/productItemUtils/getItemType.js";
import { getProductItemDisplay } from "../products/productUtils/productResponseUtils/getProductItemDisplay.js";
export const buildInvoiceMemo = async ({
org,
product,
products,
features,
org,
product,
products,
features,
}: {
org: Organization;
product?: FullProduct;
products?: FullProduct[];
features: Feature[];
org: Organization;
product?: FullProduct;
products?: FullProduct[];
features: Feature[];
}): Promise<string> => {
if (product) {
const items = mapToProductItems({
prices: product.prices,
entitlements: product.entitlements,
features,
});
if (product) {
const items = mapToProductItems({
prices: product.prices,
entitlements: product.entitlements,
features,
});
const itemsToDisplay = ["Included features:"];
for (const item of items) {
if (!item.feature_id) continue;
const display = getProductItemDisplay({
item,
features,
currency: org.default_currency,
});
if (display?.primary_text) itemsToDisplay.push(display.primary_text);
}
const itemsToDisplay = ["Included features:"];
for (const item of items) {
if (!item.feature_id) continue;
const display = getProductItemDisplay({
item,
features,
currency: org.default_currency,
});
if (display?.primary_text) itemsToDisplay.push(display.primary_text);
}
return itemsToDisplay.join("\n");
} else if (products) {
const itemsToDisplay = ["Included features:"];
return itemsToDisplay.join("\n");
} else if (products) {
const itemsToDisplay = ["Included features:"];
for (const p of products) {
const items = mapToProductItems({
prices: p.prices,
entitlements: p.entitlements,
features,
});
for (const p of products) {
const items = mapToProductItems({
prices: p.prices,
entitlements: p.entitlements,
features,
});
console.log(
"Items: %s",
items.map((i) => i.feature_id)
);
console.log(
"Items: %s",
items.map((i) => i.feature_id),
);
for (const item of items) {
if (!item.feature_id) continue;
const display = getProductItemDisplay({
item,
features,
currency: org.default_currency,
});
console.log(
"Display for item %s: %s",
item.feature_id,
display?.primary_text
);
if (display?.primary_text) itemsToDisplay.push(display.primary_text);
}
}
for (const item of items) {
if (!item.feature_id) continue;
const display = getProductItemDisplay({
item,
features,
currency: org.default_currency,
});
console.log(
"Display for item %s: %s",
item.feature_id,
display?.primary_text,
);
if (display?.primary_text) itemsToDisplay.push(display.primary_text);
}
}
console.log("Items to display: %s", itemsToDisplay);
console.log("Items to display: %s", itemsToDisplay);
let memo = itemsToDisplay.join("\n");
if (memo.length > 490) {
memo = memo.slice(0, 490) + "...";
}
return memo;
} else return "";
let memo = itemsToDisplay.join("\n");
if (memo.length > 490) {
memo = memo.slice(0, 490) + "...";
}
return memo;
} else return "";
};
export const buildInvoiceMemoFromEntitlements = async ({
org,
entitlements,
prices,
features,
logger,
org,
entitlements,
prices,
features,
logger,
}: {
org: Organization;
entitlements: EntitlementWithFeature[];
prices: Price[];
features: Feature[];
logger: any;
org: Organization;
entitlements: EntitlementWithFeature[];
prices: Price[];
features: Feature[];
logger: any;
}) => {
// Get item from price and ent
const items = mapToProductItems({
entitlements,
prices,
features,
});
// Get item from price and ent
const items = mapToProductItems({
entitlements,
prices,
features,
});
if (items.filter(isFeatureItem).length === 0) return "";
if (items.filter(isFeatureItem).length === 0) return "";
const itemsToDisplay = ["Included:"];
const itemsToDisplay = ["Included:"];
for (const item of items) {
if (!isFeatureItem(item)) continue;
for (const item of items) {
if (!isFeatureItem(item)) continue;
const display = getProductItemDisplay({
item,
features,
currency: org.default_currency,
});
const display = getProductItemDisplay({
item,
features,
currency: org.default_currency,
});
itemsToDisplay.push(
`- ${display?.primary_text}${display?.secondary_text ? ` ${display?.secondary_text}` : ""}`
);
}
itemsToDisplay.push(
`- ${display?.primary_text}${display?.secondary_text ? ` ${display?.secondary_text}` : ""}`,
);
}
let memo = itemsToDisplay.join("\n");
if (memo.length > 490) {
memo = memo.slice(0, 490) + "...";
}
return memo;
let memo = itemsToDisplay.join("\n");
if (memo.length > 490) {
memo = `${memo.slice(0, 490)}...`;
}
return memo;
};

View File

@@ -212,3 +212,30 @@ export const updateOrgConfig = async ({
await CacheManager.disconnect();
}
};
export const unsetOrgStripeKeys = async ({
org,
env,
db,
}: {
org: Organization;
env: AppEnv;
db: DrizzleCli;
}) => {
const newStripeConfig: any = structuredClone(org.stripe_config) || {};
if (env === AppEnv.Sandbox) {
newStripeConfig.test_api_key = null;
newStripeConfig.test_webhook_secret = null;
} else {
newStripeConfig.live_api_key = null;
newStripeConfig.live_webhook_secret = null;
}
await OrgService.update({
db,
orgId: org.id,
updates: {
stripe_config: newStripeConfig,
},
});
};

View File

@@ -12,15 +12,15 @@ import {
import { ExtendedRequest } from "@/utils/models/Request.js";
import { routeHandler } from "@/utils/routerUtils.js";
import { slugify } from "@/utils/genUtils.js";
import { and, eq } from "drizzle-orm";
import { connectStripe } from "../orgs/handlers/handleConnectStripe.js";
import { z } from "zod";
import { createKey } from "../dev/api-keys/apiKeyUtils.js";
import { afterOrgCreated } from "@/utils/authUtils/afterOrgCreated.js";
import { Autumn } from "autumn-js";
import { organizationSchema } from "better-auth/plugins";
import { isStripeConnected, shouldReconnectStripe } from "../orgs/orgUtils.js";
import { shouldReconnectStripe } from "../orgs/orgUtils.js";
const platformRouter = Router();
@@ -215,7 +215,6 @@ platformRouter.post("/exchange", (req: any, res: any) =>
});
if (reconnectStripe) {
console.log("Reconnecting stripe sandbox");
let {
test_api_key,
test_webhook_secret,

View File

@@ -1,198 +1,214 @@
import RecaseError from "@/utils/errorUtils.js";
import { generateId, notNullish } from "@/utils/genUtils.js";
import {
CreateFreeTrial,
type CreateFreeTrial,
CreateFreeTrialSchema,
ErrCode,
FreeTrial,
type FreeProductConfig,
type FreeTrial,
FreeTrialDuration,
Price,
type Price
} from "@autumn/shared";
import { addDays, addMinutes, addMonths, addYears } from "date-fns";
import { FreeTrialService } from "./FreeTrialService.js";
import { DrizzleCli } from "@/db/initDrizzle.js";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import { CusProductService } from "@/internal/customers/cusProducts/CusProductService.js";
import { isFreeProduct, isOneOff } from "../productUtils.js";
import RecaseError from "@/utils/errorUtils.js";
import { generateId } from "@/utils/genUtils.js";
import { isOneOff } from "../productUtils.js";
import { FreeTrialService } from "./FreeTrialService.js";
export const validateOneOffTrial = async ({
prices,
freeTrial,
prices,
freeTrial,
}: {
prices: Price[];
freeTrial: FreeTrial | null;
prices: Price[];
freeTrial: FreeTrial | null;
}) => {
if(isOneOff(prices) && freeTrial) {
throw new RecaseError({
message: "One-off products cannot have a free trial",
code: ErrCode.InvalidRequest,
statusCode: 400,
});
}
}
if (isOneOff(prices) && freeTrial) {
throw new RecaseError({
message: "One-off products cannot have a free trial",
code: ErrCode.InvalidRequest,
statusCode: 400,
});
}
};
export const validateAndInitFreeTrial = ({
freeTrial,
internalProductId,
isCustom = false,
freeTrial,
internalProductId,
isCustom = false,
}: {
freeTrial: CreateFreeTrial;
internalProductId: string;
isCustom?: boolean;
freeTrial: CreateFreeTrial;
internalProductId: string;
isCustom?: boolean;
}): FreeTrial => {
const freeTrialSchema = CreateFreeTrialSchema.parse(freeTrial);
const freeTrialSchema = CreateFreeTrialSchema.parse(freeTrial);
return {
...freeTrialSchema,
id: generateId("ft"),
created_at: Date.now(),
duration: freeTrial.duration || FreeTrialDuration.Day,
internal_product_id: internalProductId,
is_custom: isCustom,
card_required: freeTrial.card_required ?? true,
};
return {
...freeTrialSchema,
id: generateId("ft"),
created_at: Date.now(),
duration: freeTrial.duration || FreeTrialDuration.Day,
internal_product_id: internalProductId,
is_custom: isCustom,
card_required: freeTrial.card_required ?? true,
};
};
export const freeTrialsAreSame = ({
ft1,
ft2,
ft1,
ft2,
}: {
ft1?: FreeTrial | CreateFreeTrial | null;
ft2?: FreeTrial | CreateFreeTrial | null;
ft1?: FreeTrial | CreateFreeTrial | null;
ft2?: FreeTrial | CreateFreeTrial | null;
}) => {
if (!ft1 && !ft2) return true;
if (!ft1 || !ft2) return false;
return (
ft1.length === ft2.length &&
ft1.unique_fingerprint === ft2.unique_fingerprint &&
ft1.duration === ft2.duration &&
ft1.card_required === ft2.card_required
);
if (!ft1 && !ft2) return true;
if (!ft1 || !ft2) return false;
return (
ft1.length === ft2.length &&
ft1.unique_fingerprint === ft2.unique_fingerprint &&
ft1.duration === ft2.duration &&
ft1.card_required === ft2.card_required
);
};
export const freeTrialToStripeTimestamp = ({
freeTrial,
now,
freeTrial,
now,
}: {
freeTrial: FreeTrial | null | undefined;
now?: number | undefined;
freeTrial: FreeTrial | null | undefined;
now?: number | undefined;
}) => {
now = now || Date.now();
now = now || Date.now();
if (!freeTrial) return undefined;
if (!freeTrial) return undefined;
let duration = freeTrial.duration || FreeTrialDuration.Day;
let length = freeTrial.length;
const duration = freeTrial.duration || FreeTrialDuration.Day;
const length = freeTrial.length;
let trialEnd: Date;
if (duration === FreeTrialDuration.Day) {
trialEnd = addDays(new Date(now), length);
} else if (duration === FreeTrialDuration.Month) {
trialEnd = addMonths(new Date(now), length);
} else if (duration === FreeTrialDuration.Year) {
trialEnd = addYears(new Date(now), length);
} else {
throw new RecaseError({
message: `Invalid free trial duration: ${duration}`,
code: "invalid_free_trial_duration",
statusCode: 400,
});
}
let trialEnd: Date;
if (duration === FreeTrialDuration.Day) {
trialEnd = addDays(new Date(now), length);
} else if (duration === FreeTrialDuration.Month) {
trialEnd = addMonths(new Date(now), length);
} else if (duration === FreeTrialDuration.Year) {
trialEnd = addYears(new Date(now), length);
} else {
throw new RecaseError({
message: `Invalid free trial duration: ${duration}`,
code: "invalid_free_trial_duration",
statusCode: 400,
});
}
// trialEnd = addMinutes(trialEnd, 5);
trialEnd = addMinutes(trialEnd, 10);
// trialEnd = addMinutes(trialEnd, 5);
trialEnd = addMinutes(trialEnd, 10);
return Math.ceil(trialEnd.getTime() / 1000);
return Math.ceil(trialEnd.getTime() / 1000);
};
export const rewardTrialToStripeTimestamp = ({
rewardTrial,
now,
}: {
rewardTrial: FreeProductConfig | null | undefined;
now?: number | undefined;
}) => {
now = now || Date.now();
if (!rewardTrial) return undefined;
const length = rewardTrial.duration_value;
const trialEnd: Date = addMonths(new Date(now), length);
return Math.ceil(trialEnd.getTime() / 1000);
};
export const getFreeTrialAfterFingerprint = async ({
db,
freeTrial,
productId,
fingerprint,
internalCustomerId,
multipleAllowed,
db,
freeTrial,
productId,
fingerprint,
internalCustomerId,
multipleAllowed,
}: {
db: DrizzleCli;
freeTrial: FreeTrial | null | undefined;
productId: string;
fingerprint: string | null | undefined;
internalCustomerId: string;
multipleAllowed: boolean;
db: DrizzleCli;
freeTrial: FreeTrial | null | undefined;
productId: string;
fingerprint: string | null | undefined;
internalCustomerId: string;
multipleAllowed: boolean;
}): Promise<FreeTrial | null> => {
if (!freeTrial) return null;
if (!freeTrial) return null;
if (multipleAllowed) {
return freeTrial;
}
if (multipleAllowed) {
return freeTrial;
}
let uniqueFreeTrial: FreeTrial | null = freeTrial;
let uniqueFreeTrial: FreeTrial | null = freeTrial;
const data = await CusProductService.getByFingerprint({
db,
productId,
internalCustomerId,
fingerprint: uniqueFreeTrial.unique_fingerprint ? fingerprint! : undefined,
});
const data = await CusProductService.getByFingerprint({
db,
productId,
internalCustomerId,
fingerprint: uniqueFreeTrial.unique_fingerprint ? fingerprint! : undefined,
});
const exists = data && data.length > 0;
const exists = data && data.length > 0;
if (exists) {
console.log("Free trial fingerprint exists");
uniqueFreeTrial = null;
}
if (exists) {
console.log("Free trial fingerprint exists");
uniqueFreeTrial = null;
}
return uniqueFreeTrial;
return uniqueFreeTrial;
};
export const handleNewFreeTrial = async ({
db,
newFreeTrial,
curFreeTrial,
internalProductId,
isCustom = false,
product,
db,
newFreeTrial,
curFreeTrial,
internalProductId,
isCustom = false,
product,
}: {
db: DrizzleCli;
newFreeTrial: CreateFreeTrial | null;
curFreeTrial: FreeTrial | null | undefined;
internalProductId: string;
isCustom: boolean;
product?: any; // Add product parameter for validation
db: DrizzleCli;
newFreeTrial: CreateFreeTrial | null;
curFreeTrial: FreeTrial | null | undefined;
internalProductId: string;
isCustom: boolean;
product?: any; // Add product parameter for validation
}) => {
// If new free trial is null
if (!newFreeTrial) {
if (!isCustom && curFreeTrial) {
await FreeTrialService.delete({
db,
id: curFreeTrial.id,
});
}
return null;
}
// If new free trial is null
if (!newFreeTrial) {
if (!isCustom && curFreeTrial) {
await FreeTrialService.delete({
db,
id: curFreeTrial.id,
});
}
return null;
}
if (freeTrialsAreSame({ ft1: curFreeTrial, ft2: newFreeTrial })) {
return curFreeTrial;
}
if (freeTrialsAreSame({ ft1: curFreeTrial, ft2: newFreeTrial })) {
return curFreeTrial;
}
const createdFreeTrial = validateAndInitFreeTrial({
freeTrial: newFreeTrial,
internalProductId,
isCustom,
});
const createdFreeTrial = validateAndInitFreeTrial({
freeTrial: newFreeTrial,
internalProductId,
isCustom,
});
if (isCustom && newFreeTrial) {
await FreeTrialService.insert({
db,
data: createdFreeTrial,
});
} else if (!isCustom) {
createdFreeTrial.id = curFreeTrial?.id || createdFreeTrial.id;
if (isCustom && newFreeTrial) {
await FreeTrialService.insert({
db,
data: createdFreeTrial,
});
} else if (!isCustom) {
createdFreeTrial.id = curFreeTrial?.id || createdFreeTrial.id;
await FreeTrialService.upsert({
db,
data: createdFreeTrial,
});
}
await FreeTrialService.upsert({
db,
data: createdFreeTrial,
});
}
return createdFreeTrial;
return createdFreeTrial;
};

View File

@@ -44,11 +44,6 @@ export const handleListProductsBeta = async (req: any, res: any) =>
})(),
]);
console.log(
"Products:",
products.map((p) => `${p.internal_id} - ${p.id} - ${p.archived}`)
);
if (req.query.v1_schema === "true") {
res.status(200).json({
list: products,

View File

@@ -1,49 +1,46 @@
import {
AppEnv,
type AppEnv,
BillingInterval,
BillingType,
CreateProduct,
Entitlement,
type CreateProduct,
EntInterval,
type Entitlement,
EntitlementSchema,
ErrCode,
Feature,
FixedPriceConfig,
type Feature,
type FixedPriceConfig,
type FullProduct,
intervalsSame,
Organization,
Price,
type Organization,
type Price,
PriceSchema,
PriceType,
ProcessorType,
Product,
ProductOptions,
type Product,
ProductSchema,
UsagePriceConfig,
type UsagePriceConfig,
} from "@autumn/shared";
import { FullProduct } from "@autumn/shared";
import { Decimal } from "decimal.js";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import { createStripePriceIFNotExist } from "@/external/stripe/createStripePrice/createStripePrice.js";
import { createStripeCli } from "@/external/stripe/utils.js";
import {
getBillingInterval,
getBillingType,
} from "@/internal/products/prices/priceUtils.js";
import { createStripeCli } from "@/external/stripe/utils.js";
import { ProductService } from "./ProductService.js";
import {
import RecaseError from "@/utils/errorUtils.js";
import { generateId, notNullish } from "@/utils/genUtils.js";
import type {
AttachParams,
InsertCusProductParams,
} from "../customers/cusProducts/AttachParams.js";
import {
getEntitlementsForProduct,
getEntsWithFeature,
} from "./entitlements/entitlementUtils.js";
import { Decimal } from "decimal.js";
import { generateId, notNullish } from "@/utils/genUtils.js";
import { PriceService } from "./prices/PriceService.js";
import { EntitlementService } from "./entitlements/EntitlementService.js";
import RecaseError from "@/utils/errorUtils.js";
import { createStripePriceIFNotExist } from "@/external/stripe/createStripePrice/createStripePrice.js";
import { FreeTrialService } from "./free-trials/FreeTrialService.js";
import { DrizzleCli } from "@/db/initDrizzle.js";
import { compareBillingIntervals } from "./prices/priceUtils/priceIntervalUtils.js";
import { isStripeConnected } from "../orgs/orgUtils.js";
import { EntitlementService } from "./entitlements/EntitlementService.js";
import { getEntitlementsForProduct } from "./entitlements/entitlementUtils.js";
import { FreeTrialService } from "./free-trials/FreeTrialService.js";
import { ProductService } from "./ProductService.js";
import { PriceService } from "./prices/PriceService.js";
import { compareBillingIntervals } from "./prices/priceUtils/priceIntervalUtils.js";
import { isDefaultTrialFullProduct } from "./productUtils/classifyProduct.js";
export const getLatestProducts = (products: FullProduct[]) => {
@@ -86,7 +83,7 @@ export const constructProduct = ({
processor?: any;
baseVariantId?: string | null;
}) => {
let newProduct: Product = {
const newProduct: Product = {
...productData,
org_id: orgId,
env,
@@ -129,8 +126,8 @@ export const isProductUpgrade = ({
return true;
}
let billingInterval1 = getBillingInterval(prices1); // pro quarter
let billingInterval2 = getBillingInterval(prices2); // premium
const billingInterval1 = getBillingInterval(prices1); // pro quarter
const billingInterval2 = getBillingInterval(prices2); // premium
// 2. Get total price for each product
const getTotalPrice = (prices: Price[]) => {
@@ -186,7 +183,7 @@ export const isFreeProduct = (prices: Price[]) => {
export const getOptionsFromPrices = (prices: Price[], features: Feature[]) => {
const featureToOptions: { [key: string]: any } = {};
for (const price of prices) {
if (price.config!.type == PriceType.Fixed) {
if (price.config!.type === PriceType.Fixed) {
continue;
}
@@ -231,7 +228,7 @@ export const checkStripeProductExists = async ({
logger: any;
}) => {
let createNew = false;
let stripeCli = createStripeCli({
const stripeCli = createStripeCli({
org,
env,
});
@@ -240,7 +237,7 @@ export const checkStripeProductExists = async ({
createNew = true;
} else {
try {
let stripeProduct = await stripeCli.products.retrieve(
const stripeProduct = await stripeCli.products.retrieve(
product.processor!.id
);
@@ -291,7 +288,9 @@ export const attachToInsertParams = (
// Get entity
let { internalEntityId, entityId: attachEntityId } = attachParams;
if (notNullish(entityId)) {
let entity = attachParams.customer.entities.find((e) => e.id === entityId);
const entity = attachParams.customer.entities.find(
(e) => e.id === entityId
);
if (entity) {
internalEntityId = entity.internal_id;
@@ -343,7 +342,7 @@ export const copyProduct = async ({
org_id: toOrgId,
env: toEnv,
processor: null,
base_variant_id: fromEnv == toEnv ? null : product.base_variant_id,
base_variant_id: fromEnv === toEnv ? null : product.base_variant_id,
};
const newEntitlements: Entitlement[] = [];
@@ -351,12 +350,12 @@ export const copyProduct = async ({
for (const entitlement of product.entitlements) {
// 1. Get from feature
let fromFeature = fromFeatures.find(
const fromFeature = fromFeatures.find(
(f) => f.internal_id === entitlement.internal_feature_id
);
// 2. Get to feature
let toFeature = toFeatures.find((f) => f.id === fromFeature?.id);
const toFeature = toFeatures.find((f) => f.id === fromFeature?.id);
if (!toFeature) {
throw new RecaseError({
@@ -366,7 +365,7 @@ export const copyProduct = async ({
});
}
let newId = generateId("ent");
const newId = generateId("ent");
newEntitlements.push(
EntitlementSchema.parse({
...entitlement,
@@ -382,12 +381,12 @@ export const copyProduct = async ({
newEntIds[entitlement.id!] = newId;
}
let newPrices: Price[] = [];
const newPrices: Price[] = [];
for (const price of product.prices) {
// 1. Copy price
let newPrice = structuredClone(price);
const newPrice = structuredClone(price);
let config = newPrice.config as UsagePriceConfig;
const config = newPrice.config as UsagePriceConfig;
// Clear Stripe IDs
config.stripe_meter_id = undefined;
@@ -396,11 +395,11 @@ export const copyProduct = async ({
config.stripe_price_id = undefined;
if (config.type === PriceType.Usage) {
let fromFeature = fromFeatures.find(
const fromFeature = fromFeatures.find(
(f) => f.internal_id === config.internal_feature_id
);
let toFeature = toFeatures.find((f) => f.id === fromFeature?.id);
const toFeature = toFeatures.find((f) => f.id === fromFeature?.id);
if (!toFeature) {
throw new RecaseError({
@@ -414,7 +413,7 @@ export const copyProduct = async ({
config.feature_id = toFeature.id;
// Update entitlement id
let entitlementId = newEntIds[price.entitlement_id!];
const entitlementId = newEntIds[price.entitlement_id!];
if (!entitlementId) {
throw new RecaseError({
message: `Failed to swap entitlement id for price ${price.id}`,
@@ -488,16 +487,25 @@ export const isOneOff = (prices: Price[]) => {
prices.every((p) => p.config?.interval === BillingInterval.OneOff) &&
prices.some((p) => {
if (p.config?.type === PriceType.Usage) {
let config = p.config as UsagePriceConfig;
const config = p.config as UsagePriceConfig;
return config.usage_tiers.some((t) => t.amount > 0);
} else {
let config = p.config as FixedPriceConfig;
const config = p.config as FixedPriceConfig;
return config.amount > 0;
}
})
);
};
export const itemsAreOneOff = (items: Entitlement[]) => {
return items.every(
(item) =>
item.interval === null ||
item.interval === undefined ||
item.interval === EntInterval.Lifetime
);
};
export const initProductInStripe = async ({
db,
org,

View File

@@ -1,17 +1,16 @@
import { DrizzleCli } from "@/db/initDrizzle.js";
import RecaseError from "@/utils/errorUtils.js";
import { notNullish } from "@/utils/genUtils.js";
import {
customers,
ErrCode,
type RewardRedemption,
type RewardTriggerEvent,
referralCodes,
rewardPrograms,
RewardRedemption,
rewardRedemptions,
rewards,
RewardTriggerEvent,
} from "@autumn/shared";
import { and, eq } from "drizzle-orm";
import { and, eq, inArray, or } from "drizzle-orm";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import RecaseError from "@/utils/errorUtils.js";
export class RewardRedemptionService {
static async getById({ db, id }: { db: DrizzleCli; id: string }) {
@@ -55,10 +54,10 @@ export class RewardRedemptionService {
internalRewardProgramId
? eq(
rewardRedemptions.internal_reward_program_id,
internalRewardProgramId,
internalRewardProgramId
)
: undefined,
triggered ? eq(rewardRedemptions.triggered, triggered) : undefined,
triggered ? eq(rewardRedemptions.triggered, triggered) : undefined
),
with: {
reward_program: {
@@ -92,11 +91,11 @@ export class RewardRedemptionService {
.from(rewardRedemptions)
.innerJoin(
referralCodes,
eq(rewardRedemptions.referral_code_id, referralCodes.id),
eq(rewardRedemptions.referral_code_id, referralCodes.id)
)
.innerJoin(
customers,
eq(rewardRedemptions.internal_customer_id, customers.internal_id),
eq(rewardRedemptions.internal_customer_id, customers.internal_id)
);
if (withRewardProgram) {
@@ -104,15 +103,15 @@ export class RewardRedemptionService {
rewardPrograms,
eq(
rewardRedemptions.internal_reward_program_id,
rewardPrograms.internal_id,
),
rewardPrograms.internal_id
)
);
}
const data = await query
.where(eq(referralCodes.internal_customer_id, internalCustomerId))
.limit(limit);
let processed = data.map((d) => ({
const processed = data.map((d) => ({
...d.reward_redemptions,
referral_code: d.referral_codes,
customer: d.customers,
@@ -184,34 +183,65 @@ export class RewardRedemptionService {
.from(rewardRedemptions)
.innerJoin(
referralCodes,
eq(rewardRedemptions.referral_code_id, referralCodes.id),
eq(rewardRedemptions.referral_code_id, referralCodes.id)
)
.innerJoin(
customers,
eq(rewardRedemptions.internal_customer_id, customers.internal_id)
)
.innerJoin(
rewardPrograms,
eq(
rewardRedemptions.internal_reward_program_id,
rewardPrograms.internal_id,
),
rewardPrograms.internal_id
)
)
.innerJoin(
rewards,
eq(rewardPrograms.internal_reward_id, rewards.internal_id)
)
.where(
and(
eq(referralCodes.internal_customer_id, internalCustomerId),
eq(rewardRedemptions.triggered, true),
eq(rewardRedemptions.applied, false),
),
or(
and(
eq(referralCodes.internal_customer_id, internalCustomerId),
eq(rewardRedemptions.triggered, true),
eq(rewardRedemptions.applied, false)
),
and(
eq(rewardRedemptions.internal_customer_id, internalCustomerId),
eq(rewardRedemptions.triggered, true),
eq(rewardRedemptions.redeemer_applied, false)
)
)
);
if (data.length == 0) return [];
if (data.length === 0) return [];
let processed = data.map((d) => ({
const processed = data.map((d) => ({
...d.reward_redemptions,
referral_code: d.referral_codes,
reward_program: {
...d.reward_programs,
// reward: d.rewards,
reward: d.rewards,
},
}));
return processed;
}
static async _resetCustomerRedemptions({
db,
internalCustomerId,
}: {
db: DrizzleCli;
internalCustomerId: string | string[];
}) {
if (!Array.isArray(internalCustomerId))
internalCustomerId = [internalCustomerId];
return await db
.delete(rewardRedemptions)
.where(
inArray(rewardRedemptions.internal_customer_id, internalCustomerId)
);
}
}

View File

@@ -1,164 +1,164 @@
import { AppEnv, ErrCode, Reward, rewards } from "@autumn/shared";
import { DrizzleCli } from "@/db/initDrizzle.js";
import { type AppEnv, ErrCode, type Reward, rewards } from "@autumn/shared";
import { and, desc, eq, inArray, or, sql } from "drizzle-orm";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import RecaseError from "@/utils/errorUtils.js";
import { and, arrayContains, desc, eq, inArray, or, sql } from "drizzle-orm";
export class RewardService {
static async get({
db,
idOrInternalId,
orgId,
env,
}: {
db: DrizzleCli;
idOrInternalId: string;
orgId: string;
env: AppEnv;
}) {
let result = await db.query.rewards.findFirst({
where: and(
or(
eq(rewards.id, idOrInternalId),
eq(rewards.internal_id, idOrInternalId)
),
eq(rewards.org_id, orgId),
eq(rewards.env, env)
),
});
static async get({
db,
idOrInternalId,
orgId,
env,
}: {
db: DrizzleCli;
idOrInternalId: string;
orgId: string;
env: AppEnv;
}) {
const result = await db.query.rewards.findFirst({
where: and(
or(
eq(rewards.id, idOrInternalId),
eq(rewards.internal_id, idOrInternalId),
),
eq(rewards.org_id, orgId),
eq(rewards.env, env),
),
});
if (!result) {
return null;
}
if (!result) {
return null;
}
return result as Reward;
}
return result as Reward;
}
static async getByIdOrCode({
db,
codes,
orgId,
env,
}: {
db: DrizzleCli;
codes: string[];
orgId: string;
env: AppEnv;
}) {
let reward = await db.query.rewards.findMany({
where: and(
eq(rewards.org_id, orgId),
eq(rewards.env, env),
or(
inArray(rewards.id, codes),
...codes.map(
(code) => sql`EXISTS (
static async getByIdOrCode({
db,
codes,
orgId,
env,
}: {
db: DrizzleCli;
codes: string[];
orgId: string;
env: AppEnv;
}) {
const reward = await db.query.rewards.findMany({
where: and(
eq(rewards.org_id, orgId),
eq(rewards.env, env),
or(
inArray(rewards.id, codes),
...codes.map(
(code) => sql`EXISTS (
SELECT 1 FROM unnest("promo_codes") AS elem
WHERE elem->>'code' = ${code}
)`
)
)
),
});
)`,
),
),
),
});
return reward as Reward[];
}
return reward as Reward[];
}
static async insert({
db,
data,
}: {
db: DrizzleCli;
data: Reward | Reward[];
}) {
let results = await db.insert(rewards).values(data as Reward);
return results as Reward[];
}
static async insert({
db,
data,
}: {
db: DrizzleCli;
data: Reward | Reward[];
}) {
const results = await db.insert(rewards).values(data as Reward);
return results as Reward[];
}
static async list({
db,
orgId,
env,
}: {
db: DrizzleCli;
orgId: string;
env: AppEnv;
}) {
let results = await db.query.rewards.findMany({
where: and(eq(rewards.org_id, orgId), eq(rewards.env, env)),
orderBy: [desc(rewards.internal_id)],
});
static async list({
db,
orgId,
env,
}: {
db: DrizzleCli;
orgId: string;
env: AppEnv;
}) {
const results = await db.query.rewards.findMany({
where: and(eq(rewards.org_id, orgId), eq(rewards.env, env)),
orderBy: [desc(rewards.internal_id)],
});
return results as Reward[];
}
return results as Reward[];
}
static async delete({
db,
internalId,
env,
orgId,
}: {
db: DrizzleCli;
internalId: string;
env: AppEnv;
orgId: string;
}) {
await db
.delete(rewards)
.where(
and(
eq(rewards.internal_id, internalId),
eq(rewards.env, env),
eq(rewards.org_id, orgId)
)
);
}
static async delete({
db,
internalId,
env,
orgId,
}: {
db: DrizzleCli;
internalId: string;
env: AppEnv;
orgId: string;
}) {
await db
.delete(rewards)
.where(
and(
eq(rewards.internal_id, internalId),
eq(rewards.env, env),
eq(rewards.org_id, orgId),
),
);
}
static async update({
db,
internalId,
env,
orgId,
update,
}: {
db: DrizzleCli;
internalId: string;
env: AppEnv;
orgId: string;
update: Partial<Reward>;
}) {
let result = await db
.update(rewards)
.set(update)
.where(
and(
eq(rewards.internal_id, internalId),
eq(rewards.env, env),
eq(rewards.org_id, orgId)
)
)
.returning();
static async update({
db,
internalId,
env,
orgId,
update,
}: {
db: DrizzleCli;
internalId: string;
env: AppEnv;
orgId: string;
update: Partial<Reward>;
}) {
const result = await db
.update(rewards)
.set(update)
.where(
and(
eq(rewards.internal_id, internalId),
eq(rewards.env, env),
eq(rewards.org_id, orgId),
),
)
.returning();
if (result.length === 0) {
throw new RecaseError({
message: `Reward ${internalId} not found`,
code: ErrCode.InvalidRequest,
});
}
if (result.length === 0) {
throw new RecaseError({
message: `Reward ${internalId} not found`,
code: ErrCode.InvalidRequest,
});
}
return result[0] as Reward;
}
return result[0] as Reward;
}
static async deleteByOrgId({
db,
orgId,
env,
}: {
db: DrizzleCli;
orgId: string;
env: AppEnv;
}) {
await db
.delete(rewards)
.where(and(eq(rewards.org_id, orgId), eq(rewards.env, env)));
}
static async deleteByOrgId({
db,
orgId,
env,
}: {
db: DrizzleCli;
orgId: string;
env: AppEnv;
}) {
await db
.delete(rewards)
.where(and(eq(rewards.org_id, orgId), eq(rewards.env, env)));
}
}

View File

@@ -1,32 +1,49 @@
import {
AppEnv,
CusProductStatus,
Customer,
type AppEnv,
AttachBranch,
type Customer,
ErrCode,
FullRewardProgram,
ReferralCode,
Reward,
RewardCategory,
type FullRewardProgram,
type ReferralCode,
type Reward,
RewardProgram,
RewardReceivedBy,
RewardRedemption,
type RewardRedemption,
} from "@autumn/shared";
import { CusService } from "../customers/CusService.js";
import { StatusCodes } from "http-status-codes";
import type Stripe from "stripe";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import { createStripeCusIfNotExists } from "@/external/stripe/stripeCusUtils.js";
import { createStripeCli } from "@/external/stripe/utils.js";
import Stripe from "stripe";
import { RewardRedemptionService } from "./RewardRedemptionService.js";
import { ProductService } from "../products/ProductService.js";
import { createFullCusProduct } from "../customers/add-product/createFullCusProduct.js";
import { InsertCusProductParams } from "../customers/cusProducts/AttachParams.js";
import { DrizzleCli } from "@/db/initDrizzle.js";
import { CusProductService } from "../customers/cusProducts/CusProductService.js";
import RecaseError from "@/utils/errorUtils.js";
import { StatusCodes } from "http-status-codes";
import { getRewardCat } from "./rewardUtils.js";
import { ExtendedRequest } from "@/utils/models/Request.js";
import type { ExtendedRequest } from "@/utils/models/Request.js";
import { createFullCusProduct } from "../customers/add-product/createFullCusProduct.js";
import { handleAddProduct } from "../customers/attach/attachFunctions/addProductFlow/handleAddProduct.js";
import { rewardProgramToAttachParams } from "../customers/attach/attachUtils/attachParams/convertToParams.js";
import { CusService } from "../customers/CusService.js";
import { deleteCusCache } from "../customers/cusCache/updateCachedCus.js";
import { RewardProgramService } from "./RewardProgramService.js";
import type { InsertCusProductParams } from "../customers/cusProducts/AttachParams.js";
import { ProductService } from "../products/ProductService.js";
import {
isFreeProduct,
isOneOff,
itemsAreOneOff,
} from "../products/productUtils.js";
import { RewardRedemptionService } from "./RewardRedemptionService.js";
import {
receivedByRedeemer,
receivedByReferrer,
triggerFreePaidProduct,
} from "./referralUtils/triggerFreePaidProduct.js";
export const ReferralResponseCodes = {
OwnsProduct: "has_product_already",
Success: "success",
Unknown: "unknown",
NotConfigured: "not_configured",
InternalError: "internal_error",
};
export const generateReferralCode = () => {
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
@@ -55,7 +72,7 @@ export const triggerRedemption = async ({
org: any;
env: AppEnv;
logger: any;
referralCode: ReferralCode;
referralCode: ReferralCode & { reward_program: RewardProgram };
reward: Reward;
redemption: RewardRedemption;
}) => {
@@ -73,12 +90,7 @@ export const triggerRedemption = async ({
internalId: redemption.internal_customer_id,
});
const rewardProgram = await RewardProgramService.get({
db,
orgId: org.id,
env,
id: referralCode.internal_reward_program_id!,
});
const rewardProgram = referralCode.reward_program;
if (!rewardProgram) {
throw new RecaseError({
@@ -88,9 +100,25 @@ export const triggerRedemption = async ({
});
}
let applied = false;
let redeemerApplied = false;
for (let i = 0; i < 2; i++) {
let customer = i === 0 ? referrer : redeemer;
if (i === 0 && !receivedByReferrer(rewardProgram.received_by)) {
continue;
} else if (i === 1 && !receivedByRedeemer(rewardProgram.received_by)) {
continue;
}
if (!customer) {
throw new RecaseError({
message: `Customer ${i === 0 ? "referrer" : "redeemer"} not found`,
code: ErrCode.CustomerNotFound,
statusCode: StatusCodes.NOT_FOUND,
});
}
let stripeCli = createStripeCli({
org,
env,
@@ -99,185 +127,44 @@ export const triggerRedemption = async ({
await createStripeCusIfNotExists({
db,
customer: applyToCustomer,
customer: customer,
org,
env,
logger,
});
let stripeCusId = applyToCustomer.processor.id;
let stripeCusId = customer.processor.id;
let stripeCus = (await stripeCli.customers.retrieve(
stripeCusId
)) as Stripe.Customer;
let applied = false;
if (!stripeCus.discount) {
await stripeCli.customers.update(stripeCusId, {
// @ts-ignore
coupon: reward.id,
});
applied = true;
if (i === 0) {
applied = true;
} else {
redeemerApplied = true;
}
logger.info(`Applied coupon to customer in Stripe`);
}
let updatedRedemption = await RewardRedemptionService.update({
db,
id: redemption.id,
updates: {
applied,
triggered: true,
},
});
logger.info(`Successfully triggered redemption, applied: ${applied}`);
return updatedRedemption;
}
// let applyToCustomer = await CusService.getByInternalId({
// db,
// internalId: referralCode.internal_customer_id,
// });
};
export const triggerFreeProduct = async ({
req,
db,
referralCode,
redeemer,
redemption,
rewardProgram,
org,
env,
logger,
}: {
req?: ExtendedRequest;
db: DrizzleCli;
referralCode: ReferralCode;
redeemer: Customer;
redemption: RewardRedemption;
rewardProgram: FullRewardProgram;
org: any;
env: AppEnv;
logger: any;
}) => {
logger.info(`Triggering free product reward`);
let { received_by } = rewardProgram;
let addToRedeemer = received_by === RewardReceivedBy.All;
let addToReferrer =
received_by === RewardReceivedBy.Referrer ||
received_by === RewardReceivedBy.All;
let productId = rewardProgram.reward.free_product_id!;
let fullProduct = await ProductService.getFull({
db,
idOrInternalId: productId,
orgId: org.id,
env,
});
if (!fullProduct) {
throw new RecaseError({
message: `Product ${productId} not found`,
code: ErrCode.ProductNotFound,
statusCode: StatusCodes.NOT_FOUND,
});
}
let referrer = await CusService.getByInternalId({
db,
internalId: referralCode.internal_customer_id,
});
if (!referrer) {
throw new RecaseError({
message: `Referrer ${referralCode.internal_customer_id} not found`,
code: ErrCode.CustomerNotFound,
statusCode: StatusCodes.NOT_FOUND,
});
}
let [fullReferrer, fullRedeemer] = await Promise.all([
CusService.getFull({
db,
idOrInternalId: referrer.id!,
orgId: org.id,
env,
}),
CusService.getFull({
db,
idOrInternalId: redeemer.id!,
orgId: org.id,
env,
}),
]);
let attachParams: InsertCusProductParams = {
req,
org,
product: fullProduct,
prices: fullProduct.prices,
entitlements: fullProduct.entitlements,
optionsList: [],
entities: [],
freeTrial: null,
features: [],
customer: fullReferrer,
cusProducts: fullReferrer.customer_products,
replaceables: [],
};
if (addToRedeemer) {
let redeemerAttachParams = structuredClone({
...attachParams,
customer: fullRedeemer,
cusProducts: fullRedeemer.customer_products,
});
await createFullCusProduct({
db,
attachParams: redeemerAttachParams,
logger,
});
logger.info(`✅ Added ${fullProduct.name} to redeemer`);
await deleteCusCache({
db,
customerId: fullRedeemer.id!,
org,
env,
});
}
if (addToReferrer) {
await createFullCusProduct({
db,
attachParams: {
...attachParams,
customer: fullReferrer,
cusProducts: fullReferrer.customer_products,
},
logger,
});
await deleteCusCache({
db,
customerId: fullReferrer.id!,
org,
env,
});
logger.info(`✅ Added ${fullProduct.name} to referrer`);
}
await RewardRedemptionService.update({
let updatedRedemption = await RewardRedemptionService.update({
db,
id: redemption.id,
updates: {
applied,
redeemer_applied: redeemerApplied,
triggered: true,
applied: true,
},
});
logger.info(`Successfully triggered redemption, applied: ${applied}`);
return updatedRedemption;
};

View File

@@ -0,0 +1,191 @@
import {
AttachBranch,
type Customer,
ErrCode,
type FullProduct,
type FullRewardProgram,
type ReferralCode,
type Reward,
RewardReceivedBy,
type RewardRedemption,
} from "@autumn/shared";
import { StatusCodes } from "http-status-codes";
import type Stripe from "stripe";
import { createStripeCusIfNotExists } from "@/external/stripe/stripeCusUtils.js";
import { createStripeCli } from "@/external/stripe/utils.js";
import { handleAddProduct } from "@/internal/customers/attach/attachFunctions/addProductFlow/handleAddProduct.js";
import { rewardProgramToAttachParams } from "@/internal/customers/attach/attachUtils/attachParams/convertToParams.js";
import { getCustomerSub } from "@/internal/customers/attach/attachUtils/convertAttachParams.js";
import { getDefaultAttachConfig } from "@/internal/customers/attach/attachUtils/getAttachConfig.js";
import { CusService } from "@/internal/customers/CusService.js";
import { isStripeConnected } from "@/internal/orgs/orgUtils.js";
import RecaseError from "@/utils/errorUtils.js";
import type { ExtendedRequest } from "@/utils/models/Request.js";
import { RewardRedemptionService } from "../RewardRedemptionService.js";
import { ReferralResponseCodes } from "../referralUtils.js";
export const receivedByReferrer = (received_by: RewardReceivedBy) => {
return (
received_by === RewardReceivedBy.Referrer ||
received_by === RewardReceivedBy.All
);
};
export const receivedByRedeemer = (received_by: RewardReceivedBy) => {
return received_by === RewardReceivedBy.All;
};
export const triggerFreePaidProduct = async ({
req,
referralCode,
redeemer,
rewardProgram,
fullProduct,
redemption,
}: {
req: ExtendedRequest;
referralCode: ReferralCode;
redeemer: Customer;
rewardProgram: FullRewardProgram & { reward: Reward };
fullProduct: FullProduct;
redemption: RewardRedemption;
}) => {
const { db, org, env, logger } = req;
const { received_by } = rewardProgram;
logger.info(
`Triggering free paid product reward for referral code ${referralCode.code}`,
);
const [fullReferrer, fullRedeemer] = await Promise.all([
CusService.getFull({
db,
idOrInternalId: referralCode.internal_customer_id,
orgId: org.id,
env,
withEntities: true,
withSubs: true,
}),
CusService.getFull({
db,
idOrInternalId: redeemer.id!,
orgId: org.id,
env,
withEntities: true,
withSubs: true,
}),
]);
if (!isStripeConnected({ org, env })) {
throw new RecaseError({
message: "Stripe is not connected",
code: ErrCode.StripeConfigNotFound,
statusCode: StatusCodes.BAD_REQUEST,
});
}
// Add to referrer / redeemer
const stripeCli = createStripeCli({ org, env });
const applied = [false, false]; // [referrerApplied, redeemerApplied]
for (let i = 0; i < 2; i++) {
if (i === 0 && !receivedByReferrer(received_by)) {
applied[i] = true;
continue;
}
if (i === 1 && !receivedByRedeemer(received_by)) {
applied[i] = true;
continue;
}
const fullCus = [fullReferrer, fullRedeemer][i];
const attachParams = rewardProgramToAttachParams({
req,
rewardProgram,
customer: fullCus,
product: fullProduct,
});
const { sub } = await getCustomerSub({ attachParams });
if (sub) {
console.log("Detected sub", !!sub);
const curDiscounts = (sub.discounts as Stripe.Discount[]) || [];
// If coupon already applied, don't add it again
if (
!curDiscounts.some((d: any) => d.coupon?.id === rewardProgram.reward.id)
) {
console.log("Detected no discount, adding it");
try {
await stripeCli.subscriptions.update(sub.id, {
discounts: [
...curDiscounts.map((d: Stripe.Discount) => ({
discount: d.id,
})),
{
coupon: rewardProgram.reward.id,
},
],
});
} catch (error) {
console.log("Error adding discount", error);
}
applied[i] = true;
}
} else {
// Create stripe customer if not exists
await createStripeCusIfNotExists({
db,
customer: fullCus,
org,
env,
logger,
});
await handleAddProduct({
req,
attachParams,
branch: AttachBranch.New,
config: {
...getDefaultAttachConfig(),
requirePaymentMethod: false,
},
});
applied[i] = true;
}
}
const updates = {
triggered: true,
applied: applied?.[0] || false, // referrer applied
redeemer_applied: applied?.[1] || false, // redeemer applied
};
await RewardRedemptionService.update({
db,
id: redemption.id,
updates,
});
return {
redeemer: {
applied: true,
cause: applied?.[0]
? ReferralResponseCodes.Success
: ReferralResponseCodes.OwnsProduct,
meta: {
id: fullRedeemer.id,
name: fullRedeemer.name,
email: fullRedeemer.email,
created_at: fullRedeemer.created_at,
},
},
referrer: {
applied: true,
cause: applied?.[1]
? ReferralResponseCodes.Success
: ReferralResponseCodes.OwnsProduct,
},
};
};

View File

@@ -0,0 +1,208 @@
import {
type AppEnv,
type Customer,
ErrCode,
type FullRewardProgram,
type ReferralCode,
type Reward,
RewardReceivedBy,
type RewardRedemption,
} from "@autumn/shared";
import { StatusCodes } from "http-status-codes";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import { createFullCusProduct } from "@/internal/customers/add-product/createFullCusProduct.js";
import { CusService } from "@/internal/customers/CusService.js";
import { deleteCusCache } from "@/internal/customers/cusCache/updateCachedCus.js";
import type { InsertCusProductParams } from "@/internal/customers/cusProducts/AttachParams.js";
import { ProductService } from "@/internal/products/ProductService.js";
import { isFreeProduct, isOneOff } from "@/internal/products/productUtils.js";
import RecaseError from "@/utils/errorUtils.js";
import type { ExtendedRequest } from "@/utils/models/Request.js";
import { RewardRedemptionService } from "../RewardRedemptionService.js";
import { ReferralResponseCodes } from "../referralUtils.js";
import { triggerFreePaidProduct } from "./triggerFreePaidProduct.js";
export const triggerFreeProduct = async ({
req,
db,
referralCode,
redeemer,
redemption,
rewardProgram,
org,
env,
logger,
}: {
req?: ExtendedRequest;
db: DrizzleCli;
referralCode: ReferralCode;
redeemer: Customer;
redemption: RewardRedemption;
rewardProgram: FullRewardProgram & { reward: Reward };
org: any;
env: AppEnv;
logger: any;
}) => {
logger.info(`Triggering free product reward`);
const { received_by } = rewardProgram;
const addToRedeemer = received_by === RewardReceivedBy.All;
const addToReferrer =
received_by === RewardReceivedBy.Referrer ||
received_by === RewardReceivedBy.All;
const productId = rewardProgram.reward.free_product_id!;
const fullProduct = await ProductService.getFull({
db,
idOrInternalId: productId,
orgId: org.id,
env,
});
function seedReq(req?: ExtendedRequest) {
// Seed in properties that aren't usually present dependent on the trigger type
return {
...(req || {}),
db: req?.db ? req.db : db,
org: req?.org ? req.org : org,
env: req?.env ? req.env : env,
logger: req?.logger ? req.logger : logger,
logtail: req?.logtail ? req.logtail : logger,
} as ExtendedRequest;
}
if (!isFreeProduct(fullProduct.prices) && !isOneOff(fullProduct.prices)) {
req = seedReq(req);
return await triggerFreePaidProduct({
req,
referralCode,
redeemer,
rewardProgram,
fullProduct,
redemption,
});
}
// const isPaidProduct = !isFreeProduct(fullProduct.prices);
// const isRecurring =
// !isOneOff(fullProduct.prices) && !itemsAreOneOff(fullProduct.entitlements);
if (!fullProduct) {
throw new RecaseError({
message: `Product ${productId} not found`,
code: ErrCode.ProductNotFound,
statusCode: StatusCodes.NOT_FOUND,
});
}
const [fullReferrer, fullRedeemer] = await Promise.all([
CusService.getFull({
db,
idOrInternalId: referralCode.internal_customer_id,
orgId: org.id,
env,
allowNotFound: true,
}),
CusService.getFull({
db,
idOrInternalId: redeemer.id!,
orgId: org.id,
env,
}),
]);
if (!fullReferrer) {
throw new RecaseError({
message: `Referrer (internal ID: ${referralCode.internal_customer_id}) not found`,
code: ErrCode.CustomerNotFound,
statusCode: StatusCodes.NOT_FOUND,
});
}
const attachParams: InsertCusProductParams = {
req,
org,
product: fullProduct,
prices: fullProduct.prices,
entitlements: fullProduct.entitlements,
optionsList: [],
entities: [],
freeTrial: null,
features: [],
customer: fullReferrer,
cusProducts: fullReferrer.customer_products,
replaceables: [],
};
if (addToRedeemer) {
const redeemerAttachParams = {
...structuredClone(attachParams),
customer: fullRedeemer,
cusProducts: fullRedeemer.customer_products,
};
await createFullCusProduct({
db,
attachParams: redeemerAttachParams,
logger,
});
logger.info(`✅ Added ${fullProduct.name} to redeemer`);
await deleteCusCache({
db,
customerId: fullRedeemer.id!,
org,
env,
});
}
if (addToReferrer) {
await createFullCusProduct({
db,
attachParams: {
...structuredClone(attachParams),
customer: fullReferrer,
cusProducts: fullReferrer.customer_products,
},
logger,
});
await deleteCusCache({
db,
customerId: fullReferrer.id!,
org,
env,
});
logger.info(`✅ Added ${fullProduct.name} to referrer`);
}
await RewardRedemptionService.update({
db,
id: redemption.id,
updates: {
triggered: true,
applied: true,
},
});
return {
redeemer: {
applied: addToRedeemer,
cause: addToRedeemer
? ReferralResponseCodes.Success
: ReferralResponseCodes.OwnsProduct,
meta: {
id: fullRedeemer.id,
name: fullRedeemer.name,
email: fullRedeemer.email,
created_at: fullRedeemer.created_at,
},
},
referrer: {
applied: addToReferrer,
cause: addToReferrer
? ReferralResponseCodes.Success
: ReferralResponseCodes.OwnsProduct,
},
};
};

View File

@@ -1,10 +1,18 @@
import { RewardRedemptionService } from "./RewardRedemptionService.js";
import { RewardCategory, RewardTriggerEvent } from "@autumn/shared";
import { triggerFreeProduct, triggerRedemption } from "./referralUtils.js";
import { RewardProgramService } from "../rewards/RewardProgramService.js";
import { getRewardCat } from "./rewardUtils.js";
import {
type ReferralCode,
type Reward,
RewardCategory,
type RewardProgram,
type RewardRedemption,
RewardTriggerEvent,
} from "@autumn/shared";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import { createStripeCli } from "@/external/stripe/utils.js";
import { DrizzleCli } from "@/db/initDrizzle.js";
import { RewardProgramService } from "../rewards/RewardProgramService.js";
import { RewardRedemptionService } from "./RewardRedemptionService.js";
import { triggerFreeProduct } from "./referralUtils/triggerFreeProduct.js";
import { triggerRedemption } from "./referralUtils.js";
import { getRewardCat } from "./rewardUtils.js";
export const runTriggerCheckoutReward = async ({
db,
payload,
@@ -16,15 +24,14 @@ export const runTriggerCheckoutReward = async ({
}) => {
try {
// Customer redeeming code, product they're buying
let { customer, product, org, env, subId } = payload;
let stripeCli = createStripeCli({
const { customer, product, org, env, subId } = payload;
const stripeCli = createStripeCli({
org,
env,
});
// 1. Check if redemption exists
let redemptions = await RewardRedemptionService.getByCustomer({
const redemptions = await RewardRedemptionService.getByCustomer({
db,
internalCustomerId: customer.internal_id, // customer that redeemed code
withRewardProgram: true,
@@ -33,60 +40,75 @@ export const runTriggerCheckoutReward = async ({
triggerWhen: RewardTriggerEvent.Checkout,
});
for (let redemption of redemptions) {
for (const redemption of redemptions) {
if (
!redemption ||
redemption.reward_program.when !== RewardTriggerEvent.Checkout
) {
console.info(
"No redemption found or reward program not set to checkout, skipping"
);
return;
}
let { reward_program, referral_code: referralCode } = redemption;
let { reward } = reward_program;
const { reward_program, referral_code: referralCode } =
redemption as RewardRedemption & {
reward_program: RewardProgram & { reward: Reward };
referral_code: ReferralCode;
};
const { reward } = reward_program;
logger.info(`--------------------------------`);
logger.info(`CHECKING FOR CHECKOUT REWARD, ORG: ${org.slug}`);
logger.info(
console.info(`--------------------------------`);
console.info(`CHECKING FOR CHECKOUT REWARD, ORG: ${org.slug}`);
console.info(
`Redeemed by: ${customer.name} (${customer.id}) for referral program: ${reward_program.id}`
);
logger.info(`Referral code: ${referralCode.code} (${referralCode.id})`);
console.info(`Referral code: ${referralCode.code} (${referralCode.id})`);
console.info(
`Products: ${reward_program.product_ids?.join(", ")}, ${reward_program.reward.free_product_id}`
);
if (!reward_program.product_ids.includes(product.id)) {
logger.info(
if (!reward_program.product_ids?.includes(product.id)) {
console.info(
`Product ${product.name} (${product.id}) not included in referral program, skipping`
);
return;
if (reward_program.reward.free_product_id !== product.id) {
return;
}
}
// Check for trial
let hasTrial = false;
if (subId) {
let sub = await stripeCli.subscriptions.retrieve(subId);
const sub = await stripeCli.subscriptions.retrieve(subId);
// hasTrial = Boolean(sub.trial_end && sub.trial_end > Date.now());
hasTrial = sub.status === "trialing";
}
if (hasTrial) {
logger.info(`Subscription is on trial, not triggering reward`);
console.info(`Subscription is on trial, not triggering reward`);
return;
}
// Get redemption count
let redemptionCount = await RewardProgramService.getCodeRedemptionCount({
db,
referralCodeId: referralCode.id,
});
const redemptionCount = await RewardProgramService.getCodeRedemptionCount(
{
db,
referralCodeId: referralCode.id,
}
);
if (redemptionCount >= reward_program.max_redemptions) {
logger.info(
if (redemptionCount >= reward_program.max_redemptions!) {
console.info(
`Max redemptions reached, not triggering latest redemption`
);
return;
}
let rewardCat = getRewardCat(reward);
const rewardCat = getRewardCat(reward);
if (rewardCat === RewardCategory.FreeProduct) {
await triggerFreeProduct({
req: undefined,
db,
referralCode,
redeemer: customer,
@@ -99,7 +121,10 @@ export const runTriggerCheckoutReward = async ({
} else {
await triggerRedemption({
db,
referralCode,
referralCode: {
...referralCode,
reward_program,
},
org,
env,
logger,
@@ -109,7 +134,7 @@ export const runTriggerCheckoutReward = async ({
}
}
} catch (error) {
logger.error("Failed to trigger checkout reward");
logger.error(error);
console.error("Failed to trigger checkout reward");
console.error(error);
}
};

View File

@@ -1,236 +1,241 @@
import { Job, Queue, Worker } from "bullmq";
import { runUpdateBalanceTask } from "@/trigger/updateBalanceTask.js";
import { QueueManager } from "./QueueManager.js";
import { runUpdateUsageTask } from "@/trigger/updateUsageTask.js";
import { JobName } from "./JobName.js";
import { runMigrationTask } from "@/internal/migrations/runMigrationTask.js";
import { runTriggerCheckoutReward } from "@/internal/rewards/triggerCheckoutReward.js";
import { runSaveFeatureDisplayTask } from "@/internal/features/featureUtils.js";
import { CacheManager } from "@/external/caching/CacheManager.js";
import { type Job, type Queue, Worker } from "bullmq";
import type { Logger } from "pino";
import { type DrizzleCli, initDrizzle } from "@/db/initDrizzle.js";
import { acquireLock, getRedisConnection, releaseLock } from "./lockUtils.js";
import { runActionHandlerTask } from "@/internal/analytics/runActionHandlerTask.js";
import { CacheManager } from "@/external/caching/CacheManager.js";
import { logger } from "@/external/logtail/logtailUtils.js";
import { runActionHandlerTask } from "@/internal/analytics/runActionHandlerTask.js";
import { runSaveFeatureDisplayTask } from "@/internal/features/featureUtils.js";
import { runMigrationTask } from "@/internal/migrations/runMigrationTask.js";
import { detectBaseVariant } from "@/internal/products/productUtils/detectProductVariant.js";
import { Logger } from "pino";
import { runTriggerCheckoutReward } from "@/internal/rewards/triggerCheckoutReward.js";
import { runUpdateBalanceTask } from "@/trigger/updateBalanceTask.js";
import { runUpdateUsageTask } from "@/trigger/updateUsageTask.js";
import { generateId } from "@/utils/genUtils.js";
import { JobName } from "./JobName.js";
import { acquireLock, getRedisConnection, releaseLock } from "./lockUtils.js";
import { QueueManager } from "./QueueManager.js";
const NUM_WORKERS = 10;
const actionHandlers = [
JobName.HandleProductsUpdated,
JobName.HandleCustomerCreated,
JobName.HandleProductsUpdated,
JobName.HandleCustomerCreated,
];
const { db, client } = initDrizzle({ maxConnections: 10 });
const initWorker = ({
id,
queue,
useBackup,
db,
id,
queue,
useBackup,
db,
}: {
id: number;
queue: Queue;
useBackup: boolean;
db: DrizzleCli;
id: number;
queue: Queue;
useBackup: boolean;
db: DrizzleCli;
}) => {
let worker = new Worker(
"autumn",
async (job: Job) => {
const logtail = logger.child({
context: {
worker: {
task: job.name,
data: job.data,
jobId: generateId("job"),
workerId: id,
},
},
});
const worker = new Worker(
"autumn",
async (job: Job) => {
const logtail = logger.child({
context: {
worker: {
task: job.name,
data: job.data,
jobId: generateId("job"),
workerId: id,
},
},
});
try {
if (job.name == JobName.DetectBaseVariant) {
await detectBaseVariant({
db,
curProduct: job.data.curProduct,
logger: logtail as Logger,
});
return;
}
try {
if (job.name === JobName.DetectBaseVariant) {
await detectBaseVariant({
db,
curProduct: job.data.curProduct,
logger: logtail as Logger,
});
return;
}
if (job.name == JobName.GenerateFeatureDisplay) {
await runSaveFeatureDisplayTask({
db,
feature: job.data.feature,
logger: logtail,
});
return;
}
if (job.name === JobName.GenerateFeatureDisplay) {
await runSaveFeatureDisplayTask({
db,
feature: job.data.feature,
logger: logtail,
});
return;
}
if (job.name == JobName.Migration) {
await runMigrationTask({
db,
payload: job.data,
logger: logtail,
});
return;
}
if (job.name === JobName.Migration) {
await runMigrationTask({
db,
payload: job.data,
logger: logtail,
});
return;
}
if (actionHandlers.includes(job.name as JobName)) {
await runActionHandlerTask({
queue,
job,
logger: logtail,
db,
useBackup,
});
return;
}
} catch (error: any) {
logtail.error(`Failed to process bullmq job: ${job.name}`, {
jobName: job.name,
error: {
message: error.message,
stack: error.stack,
},
});
}
if (actionHandlers.includes(job.name as JobName)) {
await runActionHandlerTask({
queue,
job,
logger: logtail,
db,
useBackup,
});
return;
}
} catch (error: any) {
logtail.error(`Failed to process bullmq job: ${job.name}`, {
jobName: job.name,
error: {
message: error.message,
stack: error.stack,
},
});
}
// TRIGGER CHECKOUT REWARD
if (job.name == JobName.TriggerCheckoutReward) {
let lockKey = `reward_trigger:${job.data.customer?.internal_id}`;
if (
!(await acquireLock({
lockKey,
timeout: 10000,
useBackup,
}))
) {
await queue.add(job.name, job.data, {
delay: 1000,
});
return;
}
// TRIGGER CHECKOUT REWARD
if (job.name === JobName.TriggerCheckoutReward) {
const lockKey = `reward_trigger:${job.data.customer?.internal_id}`;
if (
!(await acquireLock({
lockKey,
timeout: 10000,
useBackup,
}))
) {
await queue.add(job.name, job.data, {
delay: 1000,
});
logger.info(
"Lock not acquired for checkout reward, adding task to queue",
);
return;
}
try {
await runTriggerCheckoutReward({
db,
payload: job.data,
logger: logtail,
});
} catch (error) {
console.error("Error processing job:", error);
} finally {
await releaseLock({ lockKey, useBackup });
}
try {
logger.info("Running checkout reward");
await runTriggerCheckoutReward({
db,
payload: job.data,
logger: logtail,
});
logger.info("Checkout reward triggered");
} catch (error) {
logger.error("Error processing job:", error);
} finally {
logger.info("Releasing lock for checkout reward");
await releaseLock({ lockKey, useBackup });
logger.info("Lock released for checkout reward");
}
return;
}
return;
}
// EVENT HANDLERS
const { internalCustomerId } = job.data; // customerId is internal customer id
// EVENT HANDLERS
const { internalCustomerId } = job.data; // customerId is internal customer id
while (
!(await acquireLock({
lockKey: `event:${internalCustomerId}`,
timeout: 10000,
useBackup,
}))
) {
await queue.add(job.name, job.data, {
delay: 200,
});
return;
}
while (
!(await acquireLock({
lockKey: `event:${internalCustomerId}`,
timeout: 10000,
useBackup,
}))
) {
await queue.add(job.name, job.data, {
delay: 200,
});
return;
}
try {
if (job.name === JobName.UpdateBalance) {
await runUpdateBalanceTask({
payload: job.data,
logger: logtail,
db,
});
} else if (job.name === JobName.UpdateUsage) {
await runUpdateUsageTask({
payload: job.data,
logger: logtail,
db,
});
}
} catch (error) {
console.error("Error processing job:", error);
} finally {
await releaseLock({
lockKey: `event:${internalCustomerId}`,
useBackup,
});
}
},
{
...getRedisConnection({ useBackup }),
concurrency: 1,
removeOnComplete: {
count: 0,
},
removeOnFail: {
count: 0,
},
drainDelay: 1000,
maxStalledCount: 0,
}
);
try {
if (job.name === JobName.UpdateBalance) {
await runUpdateBalanceTask({
payload: job.data,
logger: logtail,
db,
});
} else if (job.name === JobName.UpdateUsage) {
await runUpdateUsageTask({
payload: job.data,
logger: logtail,
db,
});
}
} catch (error) {
console.error("Error processing job:", error);
} finally {
await releaseLock({
lockKey: `event:${internalCustomerId}`,
useBackup,
});
}
},
{
...getRedisConnection({ useBackup }),
concurrency: 1,
removeOnComplete: {
count: 0,
},
removeOnFail: {
count: 0,
},
drainDelay: 1000,
maxStalledCount: 0,
},
);
worker.on("ready", () => {
console.log(`Worker ${id} ready (${useBackup ? "BACKUP" : "MAIN"})`);
});
worker.on("ready", () => {
console.log(`Worker ${id} ready (${useBackup ? "BACKUP" : "MAIN"})`);
});
worker.on("stalled", (jobId: string) => {
console.log(`Worker ${id} stalled (${useBackup ? "BACKUP" : "MAIN"})`);
console.log("JOB ID:", jobId);
});
worker.on("stalled", (jobId: string) => {
console.log(`Worker ${id} stalled (${useBackup ? "BACKUP" : "MAIN"})`);
console.log("JOB ID:", jobId);
});
worker.on("error", async (error: any) => {
if (error.code !== "ECONNREFUSED") {
console.log("WORKER ERROR:", error.message);
}
});
worker.on("error", async (error: any) => {
if (error.code !== "ECONNREFUSED") {
console.log("WORKER ERROR:", error.message);
}
});
worker.on("failed", (job, error) => {
console.log("WORKER FAILED:", error.message);
});
worker.on("failed", (job, error) => {
console.log("WORKER FAILED:", error.message);
});
};
export const initWorkers = async () => {
const workers = [];
const workers = [];
const mainQueue = await QueueManager.getQueue({ useBackup: false });
const backupQueue = await QueueManager.getQueue({ useBackup: true });
await CacheManager.getInstance();
const mainQueue = await QueueManager.getQueue({ useBackup: false });
const backupQueue = await QueueManager.getQueue({ useBackup: true });
await CacheManager.getInstance();
for (let i = 0; i < NUM_WORKERS; i++) {
workers.push(
initWorker({
id: i,
queue: mainQueue,
useBackup: false,
db,
})
);
workers.push(
initWorker({
id: i,
queue: backupQueue,
useBackup: true,
for (let i = 0; i < NUM_WORKERS; i++) {
workers.push(
initWorker({
id: i,
queue: mainQueue,
useBackup: false,
db,
}),
);
workers.push(
initWorker({
id: i,
queue: backupQueue,
useBackup: true,
db,
})
);
}
db,
}),
);
}
// Get stalled jobs
// Get stalled jobs
return workers;
return workers;
};

View File

@@ -470,6 +470,14 @@ export const deductFromUsageBasedCusEnt = async ({
onlyUsageAllowed: true,
}) as FullCusEntWithFullCusProduct;
console.log(
"Cus ents:",
cusEnts.map(
(ce) =>
`Feature: ${ce.entitlement.feature_id}, Balance: ${ce.balance}, Usage Allowed: ${ce.usage_allowed}`
)
);
if (
!usageBasedEnt &&
feature.config?.usage_type == FeatureUsageType.Continuous
@@ -560,6 +568,8 @@ export const deductFromUsageBasedCusEnt = async ({
id: usageBasedEnt!.id,
updates,
});
console.log("Usage based cus ent balance", usageBasedEnt.balance);
};
// Main function to update customer balance

View File

@@ -111,7 +111,7 @@ export const handleRequestError = ({
logger.warn(
`RECASE WARNING (${req.org?.slug || "unknown"}): ${error.message} [${error.code}]`,
{
error: error.data,
error: error.data ?? error,
}
);

View File

@@ -280,6 +280,18 @@ export const routeHandler = async <TLoad = undefined>({
let originalUrl = req.originalUrl;
if (error instanceof Stripe.errors.StripeError) {
if (
originalUrl.includes("/exchange") &&
error.message.includes("Invalid API Key provided")
) {
req.logtail.warn(`Exchange router, invalid API Key provided`);
return res.status(400).json({
message: error.message,
code: ErrCode.InvalidRequest,
});
}
if (
originalUrl.includes("/billing_portal") &&
error.message.includes("Provide a configuration or create your default")

View File

@@ -1,14 +1,19 @@
import { DrizzleCli } from "@/db/initDrizzle.js";
import { AppEnv, Customer, Organization, ProcessorType } from "@autumn/shared";
import { createStripeCli } from "../../external/stripe/utils.js";
import { Autumn } from "autumn-js";
import {
type AppEnv,
type Customer,
type Organization,
ProcessorType,
} from "@autumn/shared";
import type { Autumn } from "autumn-js";
import type Stripe from "stripe";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import { CusService } from "@/internal/customers/CusService.js";
import { deleteCusCache } from "@/internal/customers/cusCache/updateCachedCus.js";
import {
attachPmToCus,
createStripeCustomer,
} from "../../external/stripe/stripeCusUtils.js";
import { CusService } from "@/internal/customers/CusService.js";
import Stripe from "stripe";
import { deleteCusCache } from "@/internal/customers/cusCache/updateCachedCus.js";
import { createStripeCli } from "../../external/stripe/utils.js";
import { AutumnInt } from "@/external/autumn/autumnCli.js";
export const createCusInStripe = async ({
@@ -69,14 +74,14 @@ export const initCustomer = async ({
attachPm?: "success" | "fail";
withTestClock?: boolean;
}) => {
let customerData = {
const customerData = {
id: customerId,
name: customerId,
email: `${customerId}@example.com`,
fingerprint,
};
let customer = await CusService.get({
const customer = await CusService.get({
db,
idOrInternalId: customerId,
orgId: org.id,
@@ -96,7 +101,7 @@ export const initCustomer = async ({
try {
const res = await autumn.customers.create(customerData);
let customer = (await CusService.get({
const customer = (await CusService.get({
db,
idOrInternalId: customerId,
orgId: org.id,
@@ -151,7 +156,7 @@ export const attachPaymentMethod = async ({
type: "success" | "fail";
}) => {
try {
let token = type === "fail" ? "tok_chargeCustomerFail" : "tok_visa";
const token = type === "fail" ? "tok_chargeCustomerFail" : "tok_visa";
const pm = await stripeCli.paymentMethods.create({
type: "card",
card: {
@@ -191,12 +196,12 @@ export const initCustomerV2 = async ({
attachPm?: "success" | "fail";
withTestClock?: boolean;
}) => {
let name = customerId;
let email = `${customerId}@example.com`;
let fingerprint_ = "";
const name = customerId;
const email = `${customerId}@example.com`;
const fingerprint_ = "";
const stripeCli = createStripeCli({ org, env });
let testClockId = undefined;
let testClockId;
if (withTestClock) {
const testClock = await stripeCli.testHelpers.testClocks.create({
@@ -221,7 +226,7 @@ export const initCustomerV2 = async ({
name,
email,
fingerprint: fingerprint_,
// @ts-ignore
// @ts-expect-error
stripe_id: stripeCus.id,
});

View File

@@ -40,6 +40,12 @@ if [ "$1" == "g4" ]; then
'tests/advanced/coupons/*.ts'
fi
# Group 5 - Paid referrals
if [ "$1" == "paid-referrals" ]; then
$MOCHA_SETUP && $MOCHA_CMD \
'tests/advanced/referrals/paid/*.ts'
fi
# Group 4
if [ "$1" == "g4" ]; then
$MOCHA_SETUP && $MOCHA_CMD \
@@ -68,8 +74,11 @@ elif [ "$1" == "custom" ]; then
ARG3="$3"
if [ "$ARG3" == "setup" ]; then
npx mocha --bail --timeout 10000000 'tests/00_setup.ts'
elif [ "$ARG3" == "parallel" ]; then
npx mocha --parallel --bail --timeout 10000000 "tests/$FILE_TO_TEST.ts"
else
npx mocha --bail --timeout 10000000 "tests/$FILE_TO_TEST.ts"
fi
npx mocha --bail --timeout 10000000 "tests/$FILE_TO_TEST.ts"
else
npx mocha --timeout 10000000 'tests/00_setup.ts' && npx mocha --timeout 10000000 \
'tests/**/*.ts' \
@@ -77,14 +86,6 @@ else
--ignore 'tests/alex/**/*.ts'
fi
# # TEST PARALLEL
# if [ "$1" == "basic-parallel" ]; then
# MOCHA_PARALLEL=true $MOCHA_SETUP \
@@ -116,5 +117,3 @@ fi
# && $MOCHA_CMD 'tests/advanced/arrear_prorated/*.ts' 'tests/advanced/coupons/*.ts'\
# # && $MOCHA_CMD 'tests/advanced/usage/*.ts' \
# # && $MOCHA_CMD 'tests/advanced/coupons/*.ts'\

View File

@@ -0,0 +1,255 @@
import AutumnError, { AutumnInt } from "@/external/autumn/autumnCli.js";
import {
type AppEnv,
CusExpand,
CusProductStatus,
ErrCode,
type Organization,
type ReferralCode,
type RewardRedemption,
} from "@autumn/shared";
import { assert } from "chai";
import chalk from "chalk";
import type { Stripe } from "stripe";
import { setupBefore } from "tests/before.js";
import { expectProductV1Attached } from "tests/utils/expectUtils/expectProductAttached.js";
import { advanceTestClock } from "tests/utils/stripeUtils.js";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import { CusService } from "@/internal/customers/CusService.js";
import { RewardRedemptionService } from "@/internal/rewards/RewardRedemptionService.js";
import { initCustomer } from "@/utils/scriptUtils/initCustomer.js";
import { products, referralPrograms } from "../../../global.js";
export const group = "referrals13";
describe(`${chalk.yellowBright(
"referrals13: Testing referrals - referrer on Pro, gets discount on next cycle - coupon-based"
)}`, () => {
const mainCustomerId = "main-referral-13";
const redeemer = "referral13-r1";
const redeemerPM = "success";
const autumn: AutumnInt = new AutumnInt();
let stripeCli: Stripe;
const testClockIds: string[] = [];
let referralCode: ReferralCode;
let redemption: RewardRedemption;
let db: DrizzleCli;
let org: Organization;
let env: AppEnv;
before(async function () {
await setupBefore(this);
stripeCli = this.stripeCli;
db = this.db;
org = this.org;
env = this.env;
try {
await Promise.all([
autumn.customers.delete(mainCustomerId),
autumn.customers.delete(redeemer),
RewardRedemptionService._resetCustomerRedemptions({
db,
internalCustomerId: [mainCustomerId, redeemer],
}),
]);
} catch {}
// Initialize main customer with Pro product already attached
const res = await initCustomer({
autumn: this.autumnJs,
customerId: mainCustomerId,
db,
org,
env,
attachPm: "success",
});
testClockIds.push(res.testClockId);
// Attach Pro product to main customer first
await autumn.attach({
customer_id: mainCustomerId,
product_id: products.pro.id,
});
const redeemerRes = await initCustomer({
autumn: this.autumnJs,
customerId: redeemer,
db: this.db,
org: this.org,
env: this.env,
attachPm: redeemerPM,
withTestClock: true,
});
testClockIds.push(redeemerRes.testClockId);
});
it("should advance clock 10 days before redeeming", async () => {
// Advance 10 days after Pro is attached
await Promise.all(
testClockIds.map((x) =>
advanceTestClock({
testClockId: x,
numberOfDays: 10,
waitForSeconds: 10,
stripeCli,
})
)
);
});
it("should create code once", async () => {
referralCode = await autumn.referrals.createCode({
customerId: mainCustomerId,
referralId: referralPrograms.paidProductImmediateReferrer.id,
});
assert.exists(referralCode.code);
});
it("should create redemption for redeemer and fail if redeemed again", async () => {
redemption = await autumn.referrals.redeem({
customerId: redeemer,
code: referralCode.code,
});
// Try redeem for redeemer again
try {
await autumn.referrals.redeem({
customerId: redeemer,
code: referralCode.code,
});
assert.fail("Should not be able to redeem again");
} catch (error) {
assert.instanceOf(error, AutumnError);
assert.equal(error.code, ErrCode.CustomerAlreadyRedeemedReferralCode);
}
});
it("should have referrer already on Pro, and redeemer gets free product", async () => {
const redemptionResult = await autumn.redemptions.get(redemption.id);
assert.equal(redemptionResult.redeemer_applied, true);
const mainProds = (await autumn.customers.get(mainCustomerId)).products;
const redeemerProds = (await autumn.customers.get(redeemer)).products;
// Main customer (referrer) should have the pro product (already attached)
assert.equal(mainProds.length, 1);
assert.equal(mainProds[0].id, products.pro.id);
// Redeemer should only have the free product (no pro product given in referrer-only program)
assert.equal(redeemerProds.length, 1);
assert.equal(redeemerProds[0].id, products.free.id);
expectProductV1Attached({
customer: await autumn.customers.get(mainCustomerId),
product: products.pro,
status: CusProductStatus.Active,
});
// Verify redeemer only has free product
expectProductV1Attached({
customer: await autumn.customers.get(redeemer),
product: products.free,
status: CusProductStatus.Active,
});
});
it("should advance test clock and verify referrer gets discount on next Pro cycle", async () => {
// Advance 31 days from current time to trigger next billing cycle
// Coupon was applied on day 10, lasts 30 days, so should still be active on day 31
await Promise.all(
testClockIds.map((x) =>
advanceTestClock({
testClockId: x,
numberOfDays: 31,
waitForSeconds: 25,
stripeCli,
})
)
);
// Test that main customer's Pro invoice has discount applied
const mainCustomerWithInvoices = await autumn.customers.get(
mainCustomerId,
{
expand: [CusExpand.Invoices, CusExpand.Rewards],
}
);
const proInvoice = mainCustomerWithInvoices.invoices.find((x) =>
x.product_ids.includes(products.pro.id)
);
const expectedTotal = products.pro.prices[0].config.amount;
const actualTotal = proInvoice?.total;
if (proInvoice) {
// Should have a discount applied - invoice total should be less than full Pro price ($10)
assert.isBelow(
actualTotal!,
expectedTotal, // $10 in cents
"Pro invoice should have discount applied, making it less than full price"
);
// For referrer-only reward, the discount should make it significantly cheaper or free
assert.isAtMost(
actualTotal!,
expectedTotal / 2, // $5 or less in cents - assuming at least 50% discount
"Referrer should get substantial discount on Pro product"
);
}
const dbCustomers = await Promise.all(
[mainCustomerId, redeemer].map((x) =>
CusService.getFull({
db,
idOrInternalId: x,
orgId: org.id,
env,
inStatuses: [
CusProductStatus.Active,
CusProductStatus.PastDue,
CusProductStatus.Expired,
],
})
)
);
const expectedProducts = [
[
// Main referrer - keeps Pro with discount applied
{ name: "Free", status: CusProductStatus.Expired },
{ name: "Pro", status: CusProductStatus.Active },
],
[
// Redeemer - only has free product (no reward in referrer-only program)
{ name: "Free", status: CusProductStatus.Active },
],
];
dbCustomers.forEach((customer, index) => {
const expectedProductsForCustomer = expectedProducts[index];
expectedProductsForCustomer.forEach((expectedProduct) => {
const matchingProduct = customer.customer_products.find(
(cp) =>
cp.product.name === expectedProduct.name &&
cp.status === expectedProduct.status
);
const unMatchedProduct = customer.customer_products.find(
(cp) => cp.product.name === expectedProduct.name
);
assert.exists(
matchingProduct,
`Customer ${customer.name} should have ${expectedProduct.name} product with status ${expectedProduct.status}. ${unMatchedProduct ? `However ${unMatchedProduct.product.name} with status ${unMatchedProduct.status} was found instead` : ""}`
);
});
});
});
});

View File

@@ -0,0 +1,264 @@
import {
type AppEnv,
CusExpand,
CusProductStatus,
ErrCode,
type Organization,
type ReferralCode,
type RewardRedemption,
} from "@autumn/shared";
import { assert } from "chai";
import chalk from "chalk";
import type { Stripe } from "stripe";
import { setupBefore } from "tests/before.js";
import { expectProductV1Attached } from "tests/utils/expectUtils/expectProductAttached.js";
import { advanceTestClock } from "tests/utils/stripeUtils.js";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import AutumnError, { AutumnInt } from "@/external/autumn/autumnCli.js";
import { CusService } from "@/internal/customers/CusService.js";
import { RewardRedemptionService } from "@/internal/rewards/RewardRedemptionService.js";
import { initCustomer } from "@/utils/scriptUtils/initCustomer.js";
import { products, referralPrograms } from "../../../global.js";
export const group = "referrals14";
describe(`${chalk.yellowBright(
"referrals14: Testing referrals - referrer on Premium (higher tier), gets pro_amount discount - coupon-based"
)}`, () => {
const mainCustomerId = "main-referral-14";
const redeemer = "referral14-r1";
const redeemerPM = "success";
const autumn: AutumnInt = new AutumnInt();
let stripeCli: Stripe;
const testClockIds: string[] = [];
let referralCode: ReferralCode;
let redemption: RewardRedemption;
let db: DrizzleCli;
let org: Organization;
let env: AppEnv;
before(async function () {
await setupBefore(this);
stripeCli = this.stripeCli;
db = this.db;
org = this.org;
env = this.env;
try {
await Promise.all([
autumn.customers.delete(mainCustomerId, { deleteInStripe: true }),
autumn.customers.delete(redeemer, { deleteInStripe: true }),
RewardRedemptionService._resetCustomerRedemptions({
db,
internalCustomerId: [mainCustomerId, redeemer],
}),
]);
} catch {}
// Initialize main customer with Premium product already attached
const res = await initCustomer({
autumn: this.autumnJs,
customerId: mainCustomerId,
db,
org,
env,
attachPm: "success",
});
testClockIds.push(res.testClockId);
// Attach Premium product to main customer first (higher tier than Pro)
await autumn.attach({
customer_id: mainCustomerId,
product_id: products.premium.id,
});
const redeemerRes = await initCustomer({
autumn: this.autumnJs,
customerId: redeemer,
db: this.db,
org: this.org,
env: this.env,
attachPm: redeemerPM,
withTestClock: true,
});
testClockIds.push(redeemerRes.testClockId);
// Advance 10 days after Premium is attached, then redeem the code
await Promise.all(
testClockIds.map((x) =>
advanceTestClock({
testClockId: x,
numberOfDays: 10,
waitForSeconds: 5,
stripeCli,
})
)
);
});
it("should create code once", async () => {
referralCode = await autumn.referrals.createCode({
customerId: mainCustomerId,
referralId: referralPrograms.paidProductImmediateReferrer.id,
});
assert.exists(referralCode.code);
// Get referral code again
const referralCode2 = await autumn.referrals.createCode({
customerId: mainCustomerId,
referralId: referralPrograms.paidProductImmediateReferrer.id,
});
assert.equal(referralCode2.code, referralCode.code);
});
it("should create redemption for redeemer and fail if redeemed again", async () => {
redemption = await autumn.referrals.redeem({
customerId: redeemer,
code: referralCode.code,
});
// Try redeem for redeemer again
try {
await autumn.referrals.redeem({
customerId: redeemer,
code: referralCode.code,
});
assert.fail("Should not be able to redeem again");
} catch (error) {
assert.instanceOf(error, AutumnError);
assert.equal(error.code, ErrCode.CustomerAlreadyRedeemedReferralCode);
}
});
it("should have referrer already on Premium, and redeemer gets free product", async () => {
const redemptionResult = await autumn.redemptions.get(redemption.id);
assert.equal(redemptionResult.redeemer_applied, true);
const mainCus = await autumn.customers.get(mainCustomerId);
const redeemerCus = await autumn.customers.get(redeemer);
const mainProds = mainCus.products;
const redeemerProds = redeemerCus.products;
// Main customer (referrer) should have the premium product (already attached)
assert.equal(mainProds.length, 1);
assert.equal(mainProds[0].id, products.premium.id);
// Redeemer should only have the free product (no pro product given in referrer-only program)
assert.equal(redeemerProds.length, 1);
assert.equal(redeemerProds[0].id, products.free.id);
expectProductV1Attached({
customer: mainCus,
product: products.premium,
status: CusProductStatus.Active,
});
// Verify redeemer only has free product
expectProductV1Attached({
customer: redeemerCus,
product: products.free,
status: CusProductStatus.Active,
});
});
it("should advance test clock and verify referrer gets pro_amount discount on Premium cycle", async () => {
// Advance 21 more days (total 31 days from start) to trigger next billing cycle
// Coupon was applied on day 10, lasts 30 days, so should still be active on day 31
await Promise.all(
testClockIds.map((x) =>
advanceTestClock({
testClockId: x,
numberOfDays: 31,
waitForSeconds: 25,
stripeCli,
})
)
);
// Test that main customer's Premium invoice has pro_amount discount applied
const mainCustomerWithInvoices = await autumn.customers.get(
mainCustomerId,
{
expand: [CusExpand.Invoices],
}
);
const premiumInvoice = mainCustomerWithInvoices.invoices.find((x) =>
x.product_ids.includes(products.premium.id)
);
if (premiumInvoice) {
// Premium costs $50, Pro costs $10 - so referrer should get $10 discount on Premium
// Expected: Premium ($50) - Pro amount ($10) = $40
console.log(products.premium.prices);
const premiumPrice = products.premium.prices[0].config.amount; // $50
const proAmount = products.pro.prices[0].config.amount; // $10 (pro_amount discount)
const expectedTotal = premiumPrice - proAmount; // $40
// The invoice total should be exactly Premium price minus pro_amount
assert.equal(
premiumInvoice.total,
expectedTotal,
`Premium invoice should be $40 (Premium $50 - Pro amount $10 discount). Got $${premiumInvoice.total}`
);
// Verify that the discount was applied (total is less than full Premium price)
assert.isBelow(
premiumInvoice.total,
premiumPrice,
"Referrer on Premium should get pro_amount discount, making it less than full Premium price"
);
}
const dbCustomers = await Promise.all(
[mainCustomerId, redeemer].map((x) =>
CusService.getFull({
db,
idOrInternalId: x,
orgId: org.id,
env,
inStatuses: [
CusProductStatus.Active,
CusProductStatus.PastDue,
CusProductStatus.Expired,
],
})
)
);
const expectedProducts = [
[
// Main referrer - keeps Premium with pro_amount discount applied
{ name: "Free", status: CusProductStatus.Expired },
{ name: "Premium", status: CusProductStatus.Active },
],
[
// Redeemer - only has free product (no reward in referrer-only program)
{ name: "Free", status: CusProductStatus.Active },
],
];
dbCustomers.forEach((customer, index) => {
const expectedProductsForCustomer = expectedProducts[index];
expectedProductsForCustomer.forEach((expectedProduct) => {
const matchingProduct = customer.customer_products.find(
(cp) =>
cp.product.name === expectedProduct.name &&
cp.status === expectedProduct.status
);
const unMatchedProduct = customer.customer_products.find(
(cp) => cp.product.name === expectedProduct.name
);
assert.exists(
matchingProduct,
`Customer ${customer.name} should have ${expectedProduct.name} product with status ${expectedProduct.status}. ${unMatchedProduct ? `However ${unMatchedProduct.product.name} with status ${unMatchedProduct.status} was found instead` : ""}`
);
});
});
});
});

View File

@@ -0,0 +1,296 @@
import {
type AppEnv,
CusExpand,
CusProductStatus,
ErrCode,
type Organization,
type ReferralCode,
type RewardRedemption,
} from "@autumn/shared";
import { assert } from "chai";
import chalk from "chalk";
import type { Stripe } from "stripe";
import { setupBefore } from "tests/before.js";
import { expectProductV1Attached } from "tests/utils/expectUtils/expectProductAttached.js";
import { advanceTestClock } from "tests/utils/stripeUtils.js";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import AutumnError, { AutumnInt } from "@/external/autumn/autumnCli.js";
import { CusService } from "@/internal/customers/CusService.js";
import { RewardRedemptionService } from "@/internal/rewards/RewardRedemptionService.js";
import { initCustomer } from "@/utils/scriptUtils/initCustomer.js";
import { products, referralPrograms } from "../../../global.js";
export const group = "referrals15";
describe(`${chalk.yellowBright(
"referrals15: Testing referrals - referrer starts with no product, gets pro_amount discount - immediate, both - coupon-based"
)}`, () => {
const mainCustomerId = "main-referral-15";
const redeemer = "referral15-r1";
const redeemerPM = "success";
const autumn: AutumnInt = new AutumnInt();
let stripeCli: Stripe;
const testClockIds: string[] = [];
let referralCode: ReferralCode;
let redemption: RewardRedemption;
let db: DrizzleCli;
let org: Organization;
let env: AppEnv;
before(async function () {
await setupBefore(this);
stripeCli = this.stripeCli;
db = this.db;
org = this.org;
env = this.env;
try {
await Promise.all([
autumn.customers.delete(mainCustomerId, { deleteInStripe: true }),
autumn.customers.delete(redeemer, { deleteInStripe: true }),
RewardRedemptionService._resetCustomerRedemptions({
db,
internalCustomerId: [mainCustomerId, redeemer],
}),
]);
} catch {}
// Initialize main customer with NO paid product (just free tier)
const res = await initCustomer({
autumn: this.autumnJs,
customerId: mainCustomerId,
db,
org,
env,
attachPm: "success",
});
testClockIds.push(res.testClockId);
const redeemerRes = await initCustomer({
autumn: this.autumnJs,
customerId: redeemer,
db: this.db,
org: this.org,
env: this.env,
attachPm: redeemerPM,
withTestClock: true,
});
testClockIds.push(redeemerRes.testClockId);
});
it("should advance clock 10 days before redeeming", async () => {
// Advance 10 days after setup
await Promise.all(
testClockIds.map((x) =>
advanceTestClock({
testClockId: x,
numberOfDays: 10,
waitForSeconds: 10,
stripeCli,
})
)
);
});
it("should create code once", async () => {
referralCode = await autumn.referrals.createCode({
customerId: mainCustomerId,
referralId: referralPrograms.paidProductImmediateAll.id,
});
assert.exists(referralCode.code);
// Get referral code again
const referralCode2 = await autumn.referrals.createCode({
customerId: mainCustomerId,
referralId: referralPrograms.paidProductImmediateAll.id,
});
assert.equal(referralCode2.code, referralCode.code);
});
it("should create redemption for redeemer and fail if redeemed again", async () => {
redemption = await autumn.referrals.redeem({
customerId: redeemer,
code: referralCode.code,
});
// Try redeem for redeemer again
try {
await autumn.referrals.redeem({
customerId: redeemer,
code: referralCode.code,
});
assert.fail("Should not be able to redeem again");
} catch (error) {
assert.instanceOf(error, AutumnError);
assert.equal(error.code, ErrCode.CustomerAlreadyRedeemedReferralCode);
}
});
it("should have both referrer and redeemer get pro product", async () => {
const redemptionResult = await autumn.redemptions.get(redemption.id);
assert.equal(redemptionResult.redeemer_applied, true);
const mainCus = await autumn.customers.get(mainCustomerId);
const redeemerCus = await autumn.customers.get(redeemer);
const mainProds = mainCus.products;
const redeemerProds = redeemerCus.products;
// Main customer (referrer) should now have the pro product
assert.equal(mainProds.length, 1);
assert.equal(mainProds[0].id, products.pro.id);
// Redeemer should also have the pro product (both get reward)
assert.equal(redeemerProds.length, 1);
assert.equal(redeemerProds[0].id, products.pro.id);
expectProductV1Attached({
customer: mainCus,
product: products.pro,
status: CusProductStatus.Active,
});
expectProductV1Attached({
customer: redeemerCus,
product: products.pro,
status: CusProductStatus.Active,
});
});
it("should advance test clock and verify both customers get pro_amount discount on Pro cycle", async () => {
// Advance 31 days from current time to trigger next billing cycle
// Coupon was applied on day 10, lasts 30 days, so should still be active on day 31
await Promise.all(
testClockIds.map((x) =>
advanceTestClock({
testClockId: x,
numberOfDays: 31,
waitForSeconds: 25,
stripeCli,
})
)
);
// Test that both customers' Pro invoices have pro_amount discount applied
const [mainCustomerWithInvoices, redeemerWithInvoices] = await Promise.all([
autumn.customers.get(mainCustomerId, {
expand: [CusExpand.Invoices],
}),
autumn.customers.get(redeemer, {
expand: [CusExpand.Invoices],
}),
]);
// console.log(
// "Main Customer Invoices:\n",
// mainCustomerWithInvoices.invoices
// .map(
// (x) =>
// `${x.product_ids.join(", ")}: ${x.total} | ${new Date(x.created_at).toLocaleDateString()}`,
// )
// .join("\n"),
// );
// console.log(
// "Redeemer Invoices:\n",
// redeemerWithInvoices.invoices
// .map(
// (x) =>
// `${x.product_ids.join(", ")}: ${x.total} | ${new Date(x.created_at).toLocaleDateString()}`,
// )
// .join("\n"),
// );
// Check main customer (referrer) invoice
const mainProInvoice = mainCustomerWithInvoices.invoices.find((x) =>
x.product_ids.includes(products.pro.id)
);
if (mainProInvoice) {
// Pro costs $10, so with pro_amount discount it should be $0 (Pro - Pro amount = $0)
const proPrice = products.pro.prices[0].config.amount; // $10
const proAmount = products.pro.prices[0].config.amount; // $10 (pro_amount discount)
const expectedTotal = proPrice - proAmount; // $0
// console.log("Main customer expected total:", expectedTotal);
// console.log("Main customer Pro invoice total:", mainProInvoice.total);
assert.equal(
mainProInvoice.total,
expectedTotal,
`Main customer Pro invoice should be $0 (Pro $10 - Pro amount $10 discount). Got $${mainProInvoice.total}`
);
}
// Check redeemer invoice
const redeemerProInvoice = redeemerWithInvoices.invoices.find((x) =>
x.product_ids.includes(products.pro.id)
);
if (redeemerProInvoice) {
// Pro costs $10, so with pro_amount discount it should be $0 (Pro - Pro amount = $0)
const proPrice = products.pro.prices[0].config.amount; // $10
const proAmount = products.pro.prices[0].config.amount; // $10 (pro_amount discount)
const expectedTotal = proPrice - proAmount; // $0
// console.log("Redeemer expected total:", expectedTotal);
// console.log("Redeemer Pro invoice total:", redeemerProInvoice.total);
assert.equal(
redeemerProInvoice.total,
expectedTotal,
`Redeemer Pro invoice should be $0 (Pro $10 - Pro amount $10 discount). Got $${redeemerProInvoice.total}`
);
}
const dbCustomers = await Promise.all(
[mainCustomerId, redeemer].map((x) =>
CusService.getFull({
db,
idOrInternalId: x,
orgId: org.id,
env,
inStatuses: [
CusProductStatus.Active,
CusProductStatus.PastDue,
CusProductStatus.Expired,
],
})
)
);
const expectedProducts = [
[
// Main referrer - has Pro with pro_amount discount applied
{ name: "Free", status: CusProductStatus.Expired },
{ name: "Pro", status: CusProductStatus.Active },
],
[
// Redeemer - also has Pro with pro_amount discount applied
{ name: "Free", status: CusProductStatus.Expired },
{ name: "Pro", status: CusProductStatus.Active },
],
];
dbCustomers.forEach((customer, index) => {
const expectedProductsForCustomer = expectedProducts[index];
expectedProductsForCustomer.forEach((expectedProduct) => {
const matchingProduct = customer.customer_products.find(
(cp) =>
cp.product.name === expectedProduct.name &&
cp.status === expectedProduct.status
);
const unMatchedProduct = customer.customer_products.find(
(cp) => cp.product.name === expectedProduct.name
);
assert.exists(
matchingProduct,
`Customer ${customer.name} should have ${expectedProduct.name} product with status ${expectedProduct.status}. ${unMatchedProduct ? `However ${unMatchedProduct.product.name} with status ${unMatchedProduct.status} was found instead` : ""}`
);
});
});
});
});

View File

@@ -0,0 +1,351 @@
// import {
// type AppEnv,
// CusExpand,
// CusProductStatus,
// ErrCode,
// type Organization,
// type ReferralCode,
// type RewardRedemption,
// } from "@autumn/shared";
// import { assert } from "chai";
// import chalk from "chalk";
// import type { Stripe } from "stripe";
// import { setupBefore } from "tests/before.js";
// import { expectProductV1Attached } from "tests/utils/expectUtils/expectProductAttached.js";
// import {
// advanceTestClock,
// completeCheckoutForm,
// } from "tests/utils/stripeUtils.js";
// import type { DrizzleCli } from "@/db/initDrizzle.js";
// import AutumnError, { AutumnInt } from "@/external/autumn/autumnCli.js";
// import { CusService } from "@/internal/customers/CusService.js";
// import { RewardRedemptionService } from "@/internal/rewards/RewardRedemptionService.js";
// import { initCustomer } from "@/utils/scriptUtils/initCustomer.js";
// import { products, referralPrograms, rewards } from "../../../global.js";
// export const group = "referrals16";
// describe(`${chalk.yellowBright(
// "referrals16: Testing referrals - referrer starts with no product, gets pro_amount discount - checkout, both - coupon-based"
// )}`, () => {
// const mainCustomerId = "main-referral-16";
// const redeemer = "referral16-r1";
// const redeemerPM = "success";
// const autumn: AutumnInt = new AutumnInt();
// let stripeCli: Stripe;
// const testClockIds: string[] = [];
// let referralCode: ReferralCode;
// let redemption: RewardRedemption;
// let db: DrizzleCli;
// let org: Organization;
// let env: AppEnv;
// before(async function () {
// await setupBefore(this);
// stripeCli = this.stripeCli;
// db = this.db;
// org = this.org;
// env = this.env;
// try {
// await Promise.all([
// autumn.customers.delete(mainCustomerId, { deleteInStripe: true }),
// autumn.customers.delete(redeemer, { deleteInStripe: true }),
// RewardRedemptionService._resetCustomerRedemptions({
// db,
// internalCustomerId: [mainCustomerId, redeemer],
// }),
// ]);
// } catch {}
// // Initialize main customer with NO paid product (just free tier)
// const res = await initCustomer({
// autumn: this.autumnJs,
// customerId: mainCustomerId,
// db,
// org,
// env,
// attachPm: "success",
// });
// testClockIds.push(res.testClockId);
// const redeemerRes = await initCustomer({
// autumn: this.autumnJs,
// customerId: redeemer,
// db: this.db,
// org: this.org,
// env: this.env,
// attachPm: redeemerPM,
// withTestClock: true,
// });
// testClockIds.push(redeemerRes.testClockId);
// });
// it("should advance clock 10 days before redeeming", async () => {
// // Advance 10 days after setup
// await Promise.all(
// testClockIds.map((x) =>
// advanceTestClock({
// testClockId: x,
// numberOfDays: 10,
// waitForSeconds: 10,
// stripeCli,
// })
// )
// );
// });
// it("should create code once", async () => {
// referralCode = await autumn.referrals.createCode({
// customerId: mainCustomerId,
// referralId: referralPrograms.paidProductCheckoutAll.id,
// });
// assert.exists(referralCode.code);
// // Get referral code again
// const referralCode2 = await autumn.referrals.createCode({
// customerId: mainCustomerId,
// referralId: referralPrograms.paidProductCheckoutAll.id,
// });
// assert.equal(referralCode2.code, referralCode.code);
// });
// it("should create redemption for redeemer and fail if redeemed again", async () => {
// redemption = await autumn.referrals.redeem({
// customerId: redeemer,
// code: referralCode.code,
// });
// // Try redeem for redeemer again
// try {
// await autumn.referrals.redeem({
// customerId: redeemer,
// code: referralCode.code,
// });
// assert.fail("Should not be able to redeem again");
// } catch (error) {
// assert.instanceOf(error, AutumnError);
// assert.equal(error.code, ErrCode.CustomerAlreadyRedeemedReferralCode);
// }
// });
// it("should have referrer and redeemer still on free tier (reward not triggered yet)", async () => {
// const redemptionResult = await autumn.redemptions.get(redemption.id);
// assert.equal(redemptionResult.triggered, false); // Checkout trigger not fired yet
// const mainCus = await autumn.customers.get(mainCustomerId);
// const redeemerCus = await autumn.customers.get(redeemer);
// const mainProds = mainCus.products;
// const redeemerProds = redeemerCus.products;
// // Both customers should still only have free product
// assert.equal(mainProds.length, 1);
// assert.equal(mainProds[0].id, products.free.id);
// assert.equal(redeemerProds.length, 1);
// assert.equal(redeemerProds[0].id, products.free.id);
// expectProductV1Attached({
// customer: mainCus,
// product: products.free,
// status: CusProductStatus.Active,
// });
// expectProductV1Attached({
// customer: redeemerCus,
// product: products.free,
// status: CusProductStatus.Active,
// });
// });
// it("should trigger reward when redeemer checks out with Premium", async () => {
// // Redeemer purchases Premium product (triggers checkout reward)
// const checkoutRes = await autumn.attach({
// customer_id: redeemer,
// product_id: products.premium.id,
// force_checkout: true,
// });
// await completeCheckoutForm(checkoutRes.checkout_url);
// // Wait a bit for webhook processing
// await new Promise((resolve) => setTimeout(resolve, 10000));
// // Now both customers should have the reward applied
// const redemptionResult = await autumn.redemptions.get(redemption.id);
// assert.equal(redemptionResult.applied, true);
// const mainCus = await autumn.customers.get(mainCustomerId);
// const redeemerCus = await autumn.customers.get(redeemer);
// const mainProds = mainCus.products;
// const redeemerProds = redeemerCus.products;
// // Main customer (referrer) should now have the pro product
// assert.equal(mainProds.length, 1);
// assert.equal(mainProds[0].id, products.pro.id);
// // Redeemer should have both Premium (purchased) and the Pro price discount
// assert.equal(redeemerProds.length, 1);
// const redeemerStripeDiscounts = await stripeCli.subscriptions.retrieve(
// redeemerProds.find((x) => x.id === products.premium.id)
// ?.subscription_ids?.[0]!,
// {
// expand: ["discounts"],
// }
// );
// const parsedDiscountID = redeemerStripeDiscounts.discounts.find((x) => {
// if (typeof x === "string") {
// return x;
// } else if (typeof x === "object") {
// return x.coupon.id;
// } else return null;
// })!;
// assert.equal(
// redeemerStripeDiscounts.discounts.length,
// 1,
// `Redeemer Stripe Discounts: ${JSON.stringify(redeemerStripeDiscounts.discounts, null, 4)}`
// );
// assert.equal(
// typeof parsedDiscountID === "object"
// ? parsedDiscountID.coupon.id
// : parsedDiscountID,
// rewards.paidProductWithConfig.id,
// `Parsed Discount ID: ${parsedDiscountID}`
// );
// assert.exists(
// redeemerProds.find((x) => x.id === products.premium.id),
// `Redeemer must have Premium product`
// );
// expectProductV1Attached({
// customer: mainCus,
// product: products.pro,
// status: CusProductStatus.Active,
// });
// expectProductV1Attached({
// customer: redeemerCus,
// product: products.premium,
// status: CusProductStatus.Active,
// });
// });
// it("should advance test clock and verify both customers get pro_amount discount on their cycles", async () => {
// // Advance 31 days from current time to trigger next billing cycle
// await Promise.all(
// testClockIds.map((x) =>
// advanceTestClock({
// testClockId: x,
// numberOfDays: 31,
// waitForSeconds: 25,
// stripeCli,
// })
// )
// );
// // Test that both customers' invoices have pro_amount discount applied
// const [mainCustomerWithInvoices, redeemerWithInvoices] = await Promise.all([
// autumn.customers.get(mainCustomerId, {
// expand: [CusExpand.Invoices],
// }),
// autumn.customers.get(redeemer, {
// expand: [CusExpand.Invoices],
// }),
// ]);
// // Check main customer (referrer) Pro invoice
// const mainProInvoice = mainCustomerWithInvoices.invoices.find((x) =>
// x.product_ids.includes(products.pro.id)
// );
// if (mainProInvoice) {
// // Pro costs $10, so with pro_amount discount it should be $0 (Pro - Pro amount = $0)
// const proPrice = products.pro.prices[0].config.amount; // $10
// const proAmount = products.pro.prices[0].config.amount; // $10 (pro_amount discount)
// const expectedTotal = proPrice - proAmount; // $0
// // console.log("Main customer expected total:", expectedTotal);
// // console.log("Main customer Pro invoice total:", mainProInvoice.total);
// assert.equal(
// mainProInvoice.total,
// expectedTotal,
// `Main customer Pro invoice should be $0 (Pro $10 - Pro amount $10 discount). Got $${mainProInvoice.total}`
// );
// }
// // Check redeemer Premium invoice (should have $10 off)
// const redeemerPremiumInvoice = redeemerWithInvoices.invoices.find((x) =>
// x.product_ids.includes(products.premium.id)
// );
// if (redeemerPremiumInvoice) {
// // Premium costs $50, so with pro_amount discount it should be $40 (Premium - Pro amount = $40)
// const premiumPrice = products.premium.prices[0].config.amount; // $50
// const proAmount = products.pro.prices[0].config.amount; // $10 (pro_amount discount)
// const expectedTotal = premiumPrice - proAmount; // $40
// assert.equal(
// redeemerPremiumInvoice.total,
// expectedTotal,
// `Redeemer Premium invoice should be $40 (Premium $50 - Pro amount $10 discount). Got $${redeemerPremiumInvoice.total}`
// );
// }
// const dbCustomers = await Promise.all(
// [mainCustomerId, redeemer].map((x) =>
// CusService.getFull({
// db,
// idOrInternalId: x,
// orgId: org.id,
// env,
// inStatuses: [
// CusProductStatus.Active,
// CusProductStatus.PastDue,
// CusProductStatus.Expired,
// ],
// })
// )
// );
// const expectedProducts = [
// [
// // Main referrer - has Pro with pro_amount discount applied
// { name: "Free", status: CusProductStatus.Expired },
// { name: "Pro", status: CusProductStatus.Active },
// ],
// [
// // Redeemer - has both Premium (purchased) and Pro (reward) with discounts
// { name: "Free", status: CusProductStatus.Expired },
// { name: "Pro", status: CusProductStatus.Active },
// { name: "Premium", status: CusProductStatus.Active },
// ],
// ];
// dbCustomers.forEach((customer, index) => {
// const expectedProductsForCustomer = expectedProducts[index];
// expectedProductsForCustomer.forEach((expectedProduct) => {
// const matchingProduct = customer.customer_products.find(
// (cp) =>
// cp.product.name === expectedProduct.name &&
// cp.status === expectedProduct.status
// );
// const unMatchedProduct = customer.customer_products.find(
// (cp) => cp.product.name === expectedProduct.name
// );
// assert.exists(
// matchingProduct,
// `Customer ${customer.name} should have ${expectedProduct.name} product with status ${expectedProduct.status}. ${unMatchedProduct ? `However ${unMatchedProduct.product.name} with status ${unMatchedProduct.status} was found instead` : ""}`
// );
// });
// });
// });
// });

View File

@@ -126,6 +126,7 @@ describe(`${chalk.yellowBright("basic3: Testing attach one time / monthly add on
expectedAmt,
"add on balance should be correct"
);
expect(cusRes.add_ons).to.have.lengthOf(
1,
"should only have one add on product after two purchases (since they combine)"

View File

@@ -1,5 +1,7 @@
import dotenv from "dotenv";
dotenv.config();
import {
AggregateType,
AllowanceType,
@@ -7,26 +9,25 @@ import {
BillingInterval,
CouponDurationType,
EntInterval,
Feature,
type Feature,
FeatureType,
FeatureUsageType,
RewardReceivedBy,
RewardTriggerEvent,
RewardType,
} from "@autumn/shared";
import { FeatureType } from "@autumn/shared";
import { initDrizzle } from "@/db/initDrizzle.js";
import { FeatureService } from "@/internal/features/FeatureService.js";
import { OrgService } from "@/internal/orgs/OrgService.js";
import {
initReward,
initEntitlement,
initFeature,
initFreeTrial,
initPrice,
initProduct,
initReward,
initRewardProgram,
} from "./utils/init.js";
import { createSupabaseClient } from "@/external/supabaseUtils.js";
import { OrgService } from "@/internal/orgs/OrgService.js";
import { initDrizzle } from "@/db/initDrizzle.js";
import { FeatureService } from "@/internal/features/FeatureService.js";
export const features: Record<string, Feature & { eventName: string }> = {
boolean1: initFeature({
@@ -325,6 +326,26 @@ export const products = {
freeTrial: null,
isAddOn: true,
}),
proAddOn: initProduct({
id: "proAddOn",
entitlements: {
metered1: initEntitlement({
feature: features.metered1,
allowance: 100,
interval: EntInterval.Lifetime,
}),
},
prices: [
initPrice({
type: "fixed_cycle",
billingInterval: BillingInterval.OneOff,
amount: 100,
}),
],
freeTrial: null,
isAddOn: true,
}),
};
export const oneTimeProducts = {
@@ -821,6 +842,20 @@ export const rewards = {
durationType: CouponDurationType.Months,
durationValue: 1,
}),
paidProductWithConfig: initReward({
id: "paidProductWithConfig",
type: RewardType.FreeProduct,
freeProductId: products.pro.id,
freeProductConfig: {
durationType: CouponDurationType.Months,
durationValue: 1,
},
}),
paidProductAddOn: initReward({
id: "paidProductAddOn",
type: RewardType.FreeProduct,
freeProductId: products.proAddOn.id,
}),
freeProduct: initReward({
id: "freeProduct",
type: RewardType.FreeProduct,
@@ -829,6 +864,13 @@ export const rewards = {
};
export const referralPrograms = {
freeProduct: initRewardProgram({
id: "freeProduct",
internalRewardId: rewards.freeProduct.id,
when: RewardTriggerEvent.Checkout,
receivedBy: RewardReceivedBy.All,
productIds: [products.pro.id, products.proWithTrial.id],
}),
onCheckout: initRewardProgram({
id: "onCheckout",
internalRewardId: rewards.monthOff.id,
@@ -840,12 +882,66 @@ export const referralPrograms = {
internalRewardId: rewards.monthOff.id,
when: RewardTriggerEvent.CustomerCreation,
}),
freeProduct: initRewardProgram({
id: "freeProduct",
internalRewardId: rewards.freeProduct.id,
paidProductImmediateAll: initRewardProgram({
id: "paidProduct-immediate-all",
internalRewardId: rewards.paidProductWithConfig.id,
when: RewardTriggerEvent.CustomerCreation,
receivedBy: RewardReceivedBy.All,
productIds: [products.pro.id],
maxRedemptions: 100,
}),
paidProductImmediateReferrer: initRewardProgram({
id: "paidProduct-immediate-referrer",
internalRewardId: rewards.paidProductWithConfig.id,
when: RewardTriggerEvent.CustomerCreation,
receivedBy: RewardReceivedBy.Referrer,
productIds: [products.pro.id],
maxRedemptions: 100,
}),
paidProductCheckoutAll: initRewardProgram({
id: "paidProduct-checkout-all",
internalRewardId: rewards.paidProductWithConfig.id,
when: RewardTriggerEvent.Checkout,
receivedBy: RewardReceivedBy.All,
productIds: [products.pro.id, products.proWithTrial.id],
productIds: [products.premium.id],
}),
paidProductCheckoutReferrer: initRewardProgram({
id: "paidProduct-checkout-referrer",
internalRewardId: rewards.paidProductWithConfig.id,
when: RewardTriggerEvent.Checkout,
receivedBy: RewardReceivedBy.Referrer,
productIds: [products.premium.id],
}),
paidAddOnAll: initRewardProgram({
id: "paidAddOn-all",
internalRewardId: rewards.paidProductAddOn.id,
when: RewardTriggerEvent.CustomerCreation,
receivedBy: RewardReceivedBy.All,
productIds: [products.proAddOn.id],
}),
paidAddOnReferrer: initRewardProgram({
id: "paidAddOn-referrer",
internalRewardId: rewards.paidProductAddOn.id,
when: RewardTriggerEvent.CustomerCreation,
receivedBy: RewardReceivedBy.Referrer,
productIds: [products.proAddOn.id],
}),
paidAddOnCheckoutAll: initRewardProgram({
id: "paidAddOn-checkout-all",
internalRewardId: rewards.paidProductAddOn.id,
when: RewardTriggerEvent.Checkout,
receivedBy: RewardReceivedBy.All,
productIds: [products.premium.id],
}),
paidAddOnCheckoutReferrer: initRewardProgram({
id: "paidAddOn-checkout-referrer",
internalRewardId: rewards.paidProductAddOn.id,
when: RewardTriggerEvent.Checkout,
receivedBy: RewardReceivedBy.Referrer,
productIds: [products.premium.id],
}),
};
@@ -854,7 +950,7 @@ const ORG_SLUG = process.env.TESTS_ORG!;
before(async function () {
try {
this.env = AppEnv.Sandbox;
let { db, client } = initDrizzle();
const { db, client } = initDrizzle();
this.db = db;
this.client = client;
@@ -871,8 +967,8 @@ before(async function () {
const cleanFeatures = (features: Record<string, Feature>) => {
for (const featureId in features) {
let feature = features[featureId as keyof typeof features];
let dbFeature = dbFeatures.find((f: any) => f.id === feature.id);
const feature = features[featureId as keyof typeof features];
const dbFeature = dbFeatures.find((f: any) => f.id === feature.id);
if (!dbFeature) {
// throw new Error(`Feature ${feature.id} not found`);
continue;
@@ -881,7 +977,7 @@ before(async function () {
dbFeature.internal_id;
if (feature.type === FeatureType.Metered) {
// Ignore this for now
// @ts-ignore eventName is manually set
// @ts-expect-error eventName is manually set
features[featureId as keyof typeof features].eventName =
dbFeature.config?.filters[0].value[0];
}

View File

@@ -1,137 +1,197 @@
import { notNullish, nullish } from "@/utils/genUtils.js";
import { CusProductStatus, ProductV2 } from "@autumn/shared";
import { Customer } from "autumn-js";
import {
type CreateFreeTrial,
CusProductStatus,
type Entitlement,
type ProductV2,
} from "@autumn/shared";
import type { Customer, ProductItem } from "autumn-js";
import { expect } from "chai";
import { Decimal } from "decimal.js";
export const expectProductAttached = ({
customer,
product,
productId,
status,
entityId,
isCanceled = false,
quantity,
customer,
product,
productId,
status,
entityId,
isCanceled = false,
quantity,
}: {
customer: Customer;
product?: ProductV2;
productId?: string;
status?: CusProductStatus;
entityId?: string;
isCanceled?: boolean;
quantity?: number;
customer: Customer;
product?: ProductV2;
productId?: string;
status?: CusProductStatus;
entityId?: string;
isCanceled?: boolean;
quantity?: number;
}) => {
const cusProducts = customer.products;
const finalProductId = productId || product?.id;
const productAttached = cusProducts.find(
(p) =>
p.id === finalProductId && (entityId ? p.entity_id === entityId : true)
);
const cusProducts = customer.products;
const finalProductId = productId || product?.id;
const productAttached = cusProducts.find(
(p) =>
p.id === finalProductId && (entityId ? p.entity_id === entityId : true),
);
if (!productAttached) {
console.log(`product ${finalProductId} not attached`);
console.log(cusProducts);
}
if (!productAttached) {
console.log(`product ${finalProductId} not attached`);
console.log(cusProducts);
}
expect(productAttached, `product ${finalProductId} is attached`).to.exist;
expect(productAttached, `product ${finalProductId} is attached`).to.exist;
if (status) {
expect(productAttached?.status).to.equal(
status,
`product ${finalProductId} should have status ${status}`
);
} else {
expect(
productAttached?.status,
`product ${finalProductId} is not expired`
).to.not.equal(CusProductStatus.Expired);
}
if (status) {
expect(productAttached?.status).to.equal(
status,
`product ${finalProductId} should have status ${status}`,
);
} else {
expect(
productAttached?.status,
`product ${finalProductId} is not expired`,
).to.not.equal(CusProductStatus.Expired);
}
if (quantity) {
// @ts-ignore
expect(productAttached?.quantity).to.equal(quantity);
}
if (quantity) {
// @ts-expect-error
expect(productAttached?.quantity).to.equal(quantity);
}
if (entityId) {
// @ts-ignore
expect(productAttached?.entity_id).to.equal(entityId);
}
if (entityId) {
expect(productAttached?.entity_id).to.equal(entityId);
}
if (isCanceled) {
expect(productAttached?.canceled_at).to.exist;
// @ts-ignore
// expect(productAttached?.canceled).to.be.true;
}
if (isCanceled) {
expect(productAttached?.canceled_at).to.exist;
// expect(productAttached?.canceled).to.be.true;
}
};
export const expectProductV1Attached = ({
customer,
product,
productId,
status,
entityId,
}: {
customer: Customer;
product: {
id: string;
isDefault?: boolean;
isAddOn?: boolean;
items?: Record<string, ProductItem>;
entitlements: Record<string, Entitlement>;
prices: any[];
freeTrial?: CreateFreeTrial;
group?: string;
};
productId?: string;
status?: CusProductStatus;
entityId?: string;
}) => {
const cusProducts = customer.products;
const finalProductId = productId || product?.id;
const productAttached = cusProducts.find(
(p) =>
p.id === finalProductId && (entityId ? p.entity_id === entityId : true),
);
expect(productAttached, `product ${finalProductId} is attached`).to.exist;
if (status) {
expect(productAttached?.status).to.equal(
status,
`product ${finalProductId} should have status ${status}`,
);
}
};
export const expectAddOnAttached = ({
customer,
productId,
status,
}: {
customer: Customer & { add_ons: {
id: string;
status: CusProductStatus;
}[] };
productId: string;
status?: CusProductStatus;
}) => {
const addOn = customer.add_ons.find((a) => a.id === productId);
expect(addOn, `add on ${productId} is attached`).to.exist;
if (status) {
expect(addOn?.status).to.equal(status, `add on ${productId} should have status ${status}`);
}
}
export const expectInvoicesCorrect = ({
customer,
first,
// second,
customer,
first,
// second,
}: {
customer: Customer;
first: {
productId: string;
total: number;
};
// second?: {
// productId: string;
// total: number;
// };
customer: Customer;
first: {
productId: string;
total: number;
};
// second?: {
// productId: string;
// total: number;
// };
}) => {
const invoices = customer.invoices;
if (!invoices) {
console.log(`invoices is nullish`);
}
const invoices = customer.invoices;
if (!invoices) {
console.log(`invoices is nullish`);
}
try {
expect(invoices![0].total).to.approximately(
first.total,
0.01,
`invoice total is correct: ${first.total}`
);
try {
expect(invoices![0].total).to.approximately(
first.total,
0.01,
`invoice total is correct: ${first.total}`,
);
expect(invoices![0].product_ids).to.include(
first.productId,
`invoice includes product ${first.productId}`
);
} catch (error) {
console.log(`invoice for ${first.productId}, ${first.total} not found`);
throw error;
}
expect(invoices![0].product_ids).to.include(
first.productId,
`invoice includes product ${first.productId}`,
);
} catch (error) {
console.log(`invoice for ${first.productId}, ${first.total} not found`);
throw error;
}
// if (first) {
// if (first) {
// }
// }
// if (second) {
// const totalAmount = new Decimal(invoices![0].total)
// .plus(invoices![1].total)
// .toDecimalPlaces(2)
// .toNumber();
// // console.log("First invoice:", invoices![0].total, invoices![0].product_ids);
// // console.log(
// // "Second invoice:",
// // invoices![1].total,
// // invoices![1].product_ids,
// // );
// try {
// expect(totalAmount).to.approximately(
// second.total,
// 0.01,
// `first & second invoice total should sum to ${second.total}`,
// );
// expect(
// invoices![0].product_ids.includes(second.productId),
// `invoice 1 includes product ${second.productId}`,
// ).to.be.true;
// expect(
// invoices![1].product_ids.includes(second.productId),
// `invoice 2 includes product ${second.productId}`,
// ).to.be.true;
// } catch (error) {
// console.log(`invoice for ${second.productId}, ${second.total} not found`);
// throw error;
// }
// }
// if (second) {
// const totalAmount = new Decimal(invoices![0].total)
// .plus(invoices![1].total)
// .toDecimalPlaces(2)
// .toNumber();
// // console.log("First invoice:", invoices![0].total, invoices![0].product_ids);
// // console.log(
// // "Second invoice:",
// // invoices![1].total,
// // invoices![1].product_ids,
// // );
// try {
// expect(totalAmount).to.approximately(
// second.total,
// 0.01,
// `first & second invoice total should sum to ${second.total}`,
// );
// expect(
// invoices![0].product_ids.includes(second.productId),
// `invoice 1 includes product ${second.productId}`,
// ).to.be.true;
// expect(
// invoices![1].product_ids.includes(second.productId),
// `invoice 2 includes product ${second.productId}`,
// ).to.be.true;
// } catch (error) {
// console.log(`invoice for ${second.productId}, ${second.total} not found`);
// throw error;
// }
// }
};

View File

@@ -1,435 +1,441 @@
import {
AggregateType,
AllowanceType,
AppEnv,
BillingInterval,
BillWhen,
CouponDurationType,
CreateFreeTrial,
EntInterval,
Entitlement,
Feature,
FeatureType,
FeatureUsageType,
FreeTrialDuration,
Organization,
PriceType,
ProductItem,
RewardReceivedBy,
RewardTriggerEvent,
RewardType,
AggregateType,
AllowanceType,
type AppEnv,
BillingInterval,
BillWhen,
CouponDurationType,
type CreateFreeTrial,
EntInterval,
type Entitlement,
type Feature,
FeatureType,
FeatureUsageType, FreeTrialDuration,
type Organization,
PriceType,
type ProductItem,
RewardReceivedBy,
RewardTriggerEvent,
RewardType
} from "@autumn/shared";
import { getAxiosInstance } from "./setup.js";
import { SupabaseClient } from "@supabase/supabase-js";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import { attachPmToCus } from "@/external/stripe/stripeCusUtils.js";
import { generateId } from "@/utils/genUtils.js";
import { DrizzleCli } from "@/db/initDrizzle.js";
import { getAxiosInstance } from "./setup.js";
export const keyToTitle = (key: string) => {
return key
.replace(/[_-]/g, " ")
.replace(/\b\w/g, (char) => char.toUpperCase());
return key
.replace(/[_-]/g, " ")
.replace(/\b\w/g, (char) => char.toUpperCase());
};
export const initFeature = ({
id,
type,
creditSchema = [],
aggregateType = AggregateType.Sum,
groupBy = "",
eventName,
usageType = FeatureUsageType.Single,
id,
type,
creditSchema = [],
aggregateType = AggregateType.Sum,
groupBy = "",
eventName,
usageType = FeatureUsageType.Single,
}: {
id: string;
type: FeatureType;
creditSchema?: {
metered_feature_id: string;
feature_amount: number;
credit_amount: number;
}[];
aggregateType?: AggregateType;
groupBy?: string;
eventName?: string;
usageType?: FeatureUsageType;
id: string;
type: FeatureType;
creditSchema?: {
metered_feature_id: string;
feature_amount: number;
credit_amount: number;
}[];
aggregateType?: AggregateType;
groupBy?: string;
eventName?: string;
usageType?: FeatureUsageType;
}): (Feature & { eventName: string }) | any => {
const name = keyToTitle(id);
const name = keyToTitle(id);
if (type == FeatureType.Boolean) {
return {
id,
name,
type,
} as Feature;
}
if (type == FeatureType.Boolean) {
return {
id,
name,
type,
} as Feature;
}
if (type == FeatureType.Metered) {
return {
id,
name,
type,
config: {
filters: [
{
value: eventName ? [eventName] : [id],
property: "",
operator: "",
},
],
aggregate: {
type: aggregateType,
property: "value",
},
usage_type: usageType,
// group_by: groupBy
// ? {
// property: groupBy,
// }
// : undefined,
},
} as Feature;
}
if (type == FeatureType.Metered) {
return {
id,
name,
type,
config: {
filters: [
{
value: eventName ? [eventName] : [id],
property: "",
operator: "",
},
],
aggregate: {
type: aggregateType,
property: "value",
},
usage_type: usageType,
// group_by: groupBy
// ? {
// property: groupBy,
// }
// : undefined,
},
} as Feature;
}
if (type == FeatureType.CreditSystem) {
return {
id,
name,
type,
config: {
schema: creditSchema,
usage_type: FeatureUsageType.Single,
},
} as Feature;
}
if (type == FeatureType.CreditSystem) {
return {
id,
name,
type,
config: {
schema: creditSchema,
usage_type: FeatureUsageType.Single,
},
} as Feature;
}
throw new Error(`Invalid feature type: ${type}`);
throw new Error(`Invalid feature type: ${type}`);
};
export const initEntitlement = ({
feature,
allowance,
interval = EntInterval.Month,
allowanceType = AllowanceType.Fixed,
entityFeatureId,
carryFromPrevious = false,
feature,
allowance,
interval = EntInterval.Month,
allowanceType = AllowanceType.Fixed,
entityFeatureId,
carryFromPrevious = false,
}: {
feature: Feature;
allowance?: number;
interval?: EntInterval;
allowanceType?: AllowanceType;
entityFeatureId?: string;
carryFromPrevious?: boolean;
feature: Feature;
allowance?: number;
interval?: EntInterval;
allowanceType?: AllowanceType;
entityFeatureId?: string;
carryFromPrevious?: boolean;
}) => {
if (feature.type == FeatureType.Boolean) {
return {
feature_id: feature.id,
internal_feature_id: feature.internal_id,
} as Entitlement;
}
if (feature.type == FeatureType.Boolean) {
return {
feature_id: feature.id,
internal_feature_id: feature.internal_id,
} as Entitlement;
}
const isUnlimitedOrNone =
allowanceType == AllowanceType.Unlimited || allowance == null;
const isUnlimitedOrNone =
allowanceType == AllowanceType.Unlimited || allowance == null;
return {
feature_id: feature.id,
internal_feature_id: feature.internal_id,
allowance_type: allowanceType,
allowance: isUnlimitedOrNone ? null : allowance,
interval: isUnlimitedOrNone ? null : interval,
entity_feature_id: entityFeatureId,
carry_from_previous: carryFromPrevious,
created_at: Date.now(),
id: generateId("ent"),
} as Entitlement;
return {
feature_id: feature.id,
internal_feature_id: feature.internal_id,
allowance_type: allowanceType,
allowance: isUnlimitedOrNone ? null : allowance,
interval: isUnlimitedOrNone ? null : interval,
entity_feature_id: entityFeatureId,
carry_from_previous: carryFromPrevious,
created_at: Date.now(),
id: generateId("ent"),
} as Entitlement;
};
export const initPrice = ({
type,
feature,
billingInterval = BillingInterval.Month,
amount = 10.0,
oneTier = false,
billingUnits = 10,
type,
feature,
billingInterval = BillingInterval.Month,
amount = 10.0,
oneTier = false,
billingUnits = 10,
}: {
type:
| "monthly"
| "in_advance"
| "in_arrears"
| "fixed_cycle"
| "in_arrear_prorated";
feature?: Feature;
billingInterval?: BillingInterval;
amount?: number;
oneTier?: boolean;
billingUnits?: number;
type:
| "monthly"
| "in_advance"
| "in_arrears"
| "fixed_cycle"
| "in_arrear_prorated";
feature?: Feature;
billingInterval?: BillingInterval;
amount?: number;
oneTier?: boolean;
billingUnits?: number;
}) => {
if (type == "monthly" || type == "fixed_cycle") {
return {
name: type == "monthly" ? "Monthly" : "Fixed Cycle",
config: {
type: PriceType.Fixed,
amount: amount,
interval: billingInterval,
},
};
}
if (type == "monthly" || type == "fixed_cycle") {
return {
name: type == "monthly" ? "Monthly" : "Fixed Cycle",
config: {
type: PriceType.Fixed,
amount: amount,
interval: billingInterval,
},
};
}
if (!feature) {
throw new Error("Feature is required for in_advance and in_arrears");
}
if (!feature) {
throw new Error("Feature is required for in_advance and in_arrears");
}
if (type == "in_advance") {
return {
name: "In Advance",
config: {
type: PriceType.Usage,
bill_when: BillWhen.StartOfPeriod,
feature_id: feature!.id,
interval: billingInterval,
billing_units: billingUnits,
usage_tiers: [
{
from: 0,
to: -1,
amount: amount || 10.0,
},
],
},
};
}
if (type == "in_advance") {
return {
name: "In Advance",
config: {
type: PriceType.Usage,
bill_when: BillWhen.StartOfPeriod,
feature_id: feature!.id,
interval: billingInterval,
billing_units: billingUnits,
usage_tiers: [
{
from: 0,
to: -1,
amount: amount || 10.0,
},
],
},
};
}
if (type == "in_arrears" || type == "in_arrear_prorated") {
return {
name: "In Arrears",
config: {
type: PriceType.Usage,
bill_when: BillWhen.EndOfPeriod,
feature_id: feature!.id,
interval: billingInterval,
billing_units: billingUnits,
should_prorate: type == "in_arrear_prorated",
usage_tiers: oneTier
? [
{
from: 0,
to: -1,
amount: amount || 0.01,
},
]
: [
{
from: 0,
to: 10,
amount: 0.5,
},
{
from: 11,
to: -1,
amount: 0.25,
},
],
},
};
}
if (type == "in_arrears" || type == "in_arrear_prorated") {
return {
name: "In Arrears",
config: {
type: PriceType.Usage,
bill_when: BillWhen.EndOfPeriod,
feature_id: feature!.id,
interval: billingInterval,
billing_units: billingUnits,
should_prorate: type == "in_arrear_prorated",
usage_tiers: oneTier
? [
{
from: 0,
to: -1,
amount: amount || 0.01,
},
]
: [
{
from: 0,
to: 10,
amount: 0.5,
},
{
from: 11,
to: -1,
amount: 0.25,
},
],
},
};
}
};
export const initFreeTrial = ({
length,
uniqueFingerprint = false,
cardRequired = true,
length,
uniqueFingerprint = false,
cardRequired = true,
}: {
length: number;
uniqueFingerprint?: boolean;
cardRequired?: boolean;
length: number;
uniqueFingerprint?: boolean;
cardRequired?: boolean;
}): CreateFreeTrial => {
return {
length,
unique_fingerprint: uniqueFingerprint,
duration: FreeTrialDuration.Day,
card_required: cardRequired,
};
return {
length,
unique_fingerprint: uniqueFingerprint,
duration: FreeTrialDuration.Day,
card_required: cardRequired,
};
};
export const initProduct = ({
id,
isDefault = false,
isAddOn = false,
items,
entitlements,
prices,
freeTrial,
group = "",
id,
isDefault = false,
isAddOn = false,
items,
entitlements,
prices,
freeTrial,
group = "",
}: {
id: string;
isDefault?: boolean;
isAddOn?: boolean;
items?: Record<string, ProductItem>;
entitlements: Record<string, Entitlement>;
prices: any[];
freeTrial: CreateFreeTrial | null;
group?: string;
id: string;
isDefault?: boolean;
isAddOn?: boolean;
items?: Record<string, ProductItem>;
entitlements: Record<string, Entitlement>;
prices: any[];
freeTrial: CreateFreeTrial | null;
group?: string;
}) => {
// if (notNullish(items)) {
// return {
// id,
// name: keyToTitle(id),
// is_default: isDefault,
// is_add_on: isAddOn,
// group: group,
// items: items,
// free_trial: freeTrial,
// };
// }
// if (notNullish(items)) {
// return {
// id,
// name: keyToTitle(id),
// is_default: isDefault,
// is_add_on: isAddOn,
// group: group,
// items: items,
// free_trial: freeTrial,
// };
// }
return {
id,
name: keyToTitle(id),
is_default: isDefault,
is_add_on: isAddOn,
entitlements: entitlements,
prices,
free_trial: freeTrial,
group: group,
};
return {
id,
name: keyToTitle(id),
is_default: isDefault,
is_add_on: isAddOn,
entitlements: entitlements,
prices,
free_trial: freeTrial,
group: group,
};
};
export const initCustomer = async ({
customer_data,
customerId,
attachPm = false,
db,
org,
env,
testClockId,
customer_data,
customerId,
attachPm = false,
db,
org,
env,
testClockId,
}: {
customer_data?: {
id: string;
name?: string;
email?: string;
fingerprint?: string;
};
customerId?: string;
attachPm?: boolean;
db: DrizzleCli;
org: Organization;
env: AppEnv;
testClockId?: string;
customer_data?: {
id: string;
name?: string;
email?: string;
fingerprint?: string;
};
customerId?: string;
attachPm?: boolean;
db: DrizzleCli;
org: Organization;
env: AppEnv;
testClockId?: string;
}) => {
const axiosInstance = getAxiosInstance();
const axiosInstance = getAxiosInstance();
if (!customerId && !customer_data) {
throw new Error("customerId or customer_data is required");
}
if (!customerId && !customer_data) {
throw new Error("customerId or customer_data is required");
}
let customerData = customerId
? {
id: customerId,
name: customerId,
email: `${customerId}@example.com`,
}
: customer_data;
const customerData = customerId
? {
id: customerId,
name: customerId,
email: `${customerId}@example.com`,
}
: customer_data;
// Delete customer if exists
try {
await axiosInstance.delete(`/v1/customers/${customerData!.id}`);
// console.log(" - Successfully deleted customer");
} catch (error) {
// console.log("Failed to delete customer");
}
// Delete customer if exists
try {
await axiosInstance.delete(`/v1/customers/${customerData!.id}`);
// console.log(" - Successfully deleted customer");
} catch (error) {
// console.log("Failed to delete customer");
}
try {
const { data } = await axiosInstance.post(`/v1/customers`, customerData);
// Attach stripe card
try {
const { data } = await axiosInstance.post(`/v1/customers`, customerData);
// Attach stripe card
if (attachPm) {
await attachPmToCus({
customer: data.customer,
org: org,
env: env,
db: db,
testClockId: testClockId,
});
}
if (attachPm) {
await attachPmToCus({
customer: data.customer,
org: org,
env: env,
db: db,
testClockId: testClockId,
});
}
return data.customer;
} catch (error) {
console.log("Failed to create customer", error);
}
return data.customer;
} catch (error) {
console.log("Failed to create customer", error);
}
};
// Init Reward
export const initReward = ({
id,
type = RewardType.PercentageDiscount,
discountValue,
durationType = CouponDurationType.OneOff,
durationValue = 0,
onlyUsagePrices = false,
productIds,
applyToAll = false,
freeProductId,
id,
type = RewardType.PercentageDiscount,
discountValue,
durationType = CouponDurationType.OneOff,
durationValue = 0,
onlyUsagePrices = false,
productIds,
applyToAll = false,
freeProductId,
freeProductConfig,
}: {
id: string;
type?: RewardType;
discountValue?: number;
durationType?: CouponDurationType;
durationValue?: number;
onlyUsagePrices?: boolean;
productIds?: string[];
applyToAll?: boolean;
freeProductId?: string;
id: string;
type?: RewardType;
discountValue?: number;
durationType?: CouponDurationType;
durationValue?: number;
onlyUsagePrices?: boolean;
productIds?: string[];
applyToAll?: boolean;
freeProductId?: string;
freeProductConfig?: {
durationType: CouponDurationType;
durationValue: number;
};
}): any => {
if (
type == RewardType.PercentageDiscount ||
type == RewardType.FixedDiscount ||
type == RewardType.InvoiceCredits
) {
return {
id,
name: keyToTitle(id),
type,
if (
type === RewardType.PercentageDiscount ||
type === RewardType.FixedDiscount ||
type === RewardType.InvoiceCredits
) {
return {
id,
name: keyToTitle(id),
type,
only_usage_prices: onlyUsagePrices,
product_ids: productIds,
only_usage_prices: onlyUsagePrices,
product_ids: productIds,
discount_config: {
discount_value: discountValue,
duration_type: durationType,
duration_value: durationValue,
apply_to_all: applyToAll,
},
};
} else if (type == RewardType.FreeProduct) {
return {
id,
name: keyToTitle(id),
type,
free_product_id: freeProductId,
};
}
discount_config: {
discount_value: discountValue,
duration_type: durationType,
duration_value: durationValue,
apply_to_all: applyToAll,
},
};
} else if (type === RewardType.FreeProduct) {
return {
id,
name: keyToTitle(id),
type,
free_product_id: freeProductId,
free_product_config: {
duration_type: freeProductConfig?.durationType,
duration_value: freeProductConfig?.durationValue,
},
};
}
};
export const initRewardProgram = ({
id,
when = RewardTriggerEvent.CustomerCreation,
productIds = [],
internalRewardId,
maxRedemptions = 2,
receivedBy = RewardReceivedBy.Referrer,
id,
when = RewardTriggerEvent.CustomerCreation,
productIds = [],
internalRewardId,
maxRedemptions = 2,
receivedBy = RewardReceivedBy.Referrer,
}: {
id: string;
productIds?: string[];
internalRewardId: string;
when?: RewardTriggerEvent;
maxRedemptions?: number;
receivedBy?: RewardReceivedBy;
id: string;
productIds?: string[];
internalRewardId: string;
when?: RewardTriggerEvent;
maxRedemptions?: number;
receivedBy?: RewardReceivedBy;
}): any => {
return {
id,
when,
product_ids: productIds,
internal_reward_id: internalRewardId,
max_redemptions: maxRedemptions,
received_by: receivedBy,
};
return {
id,
when,
product_ids: productIds,
internal_reward_id: internalRewardId,
max_redemptions: maxRedemptions,
received_by: receivedBy,
};
};

View File

@@ -1,486 +1,486 @@
import { createSupabaseClient } from "@/external/supabaseUtils.js";
import {
AppEnv,
CreateReward,
Feature,
type CreateReward,
type Feature,
FeatureType,
FullProduct,
Organization,
Price,
type FullProduct,
type Organization,
type Price,
PriceType,
RewardProgram,
type RewardProgram,
RewardType,
} from "@autumn/shared";
import axios from "axios";
import { OrgService } from "@/internal/orgs/OrgService.js";
import { createStripeCli } from "@/external/stripe/utils.js";
import { deleteAllStripeTestClocks } from "./stripeUtils.js";
import type Stripe from "stripe";
import { AutumnCli } from "tests/cli/AutumnCli.js";
import Stripe from "stripe";
import { features as v2Features } from "tests/setup/v2Features.js";
import { initDrizzle } from "@/db/initDrizzle.js";
import { AutumnInt } from "@/external/autumn/autumnCli.js";
import { deactivateStripeMeters } from "@/external/stripe/stripeProductUtils.js";
import { mapToProductItems } from "@/internal/products/productV2Utils.js";
import { CacheManager } from "@/external/caching/CacheManager.js";
import { CacheType } from "@/external/caching/cacheActions.js";
import { hashApiKey } from "@/internal/dev/api-keys/apiKeyUtils.js";
import { initDrizzle } from "@/db/initDrizzle.js";
import { deactivateStripeMeters } from "@/external/stripe/stripeProductUtils.js";
import { createStripeCli } from "@/external/stripe/utils.js";
import { CusService } from "@/internal/customers/CusService.js";
import { ProductService } from "@/internal/products/ProductService.js";
import { RewardService } from "@/internal/rewards/RewardService.js";
import { hashApiKey } from "@/internal/dev/api-keys/apiKeyUtils.js";
import { FeatureService } from "@/internal/features/FeatureService.js";
import { features as v2Features } from "tests/setup/v2Features.js";
import { timeout } from "./genUtils.js";
import { OrgService } from "@/internal/orgs/OrgService.js";
import { ProductService } from "@/internal/products/ProductService.js";
import { mapToProductItems } from "@/internal/products/productV2Utils.js";
import { RewardService } from "@/internal/rewards/RewardService.js";
import { deleteAllStripeTestClocks } from "./stripeUtils.js";
export const getAxiosInstance = (
apiKey: string = process.env.UNIT_TEST_AUTUMN_SECRET_KEY!
apiKey: string = process.env.UNIT_TEST_AUTUMN_SECRET_KEY!,
) => {
return axios.create({
baseURL: "http://localhost:8080",
headers: {
Authorization: `Bearer ${apiKey}`,
},
});
return axios.create({
baseURL: "http://localhost:8080",
headers: {
Authorization: `Bearer ${apiKey}`,
},
});
};
export const getPublicAxiosInstance = ({
withBearer,
pkey = process.env.UNIT_TEST_AUTUMN_PUBLIC_KEY!,
withBearer,
pkey = process.env.UNIT_TEST_AUTUMN_PUBLIC_KEY!,
}: {
withBearer: boolean;
pkey?: string;
withBearer: boolean;
pkey?: string;
}) => {
let headers = withBearer
? {
Authorization: `Bearer ${pkey}`,
}
: {
"x-publishable-key": pkey,
};
return axios.create({
baseURL: "http://localhost:8080",
headers: headers,
});
const headers = withBearer
? {
Authorization: `Bearer ${pkey}`,
}
: {
"x-publishable-key": pkey,
};
return axios.create({
baseURL: "http://localhost:8080",
headers: headers,
});
};
export const clearOrg = async ({
orgSlug,
env,
orgSlug,
env,
}: {
orgSlug: string;
env?: AppEnv;
orgSlug: string;
env?: AppEnv;
}) => {
if (env !== AppEnv.Sandbox) {
console.error("Cannot clear non-sandbox orgs");
process.exit(1);
}
if (env !== AppEnv.Sandbox) {
console.error("Cannot clear non-sandbox orgs");
process.exit(1);
}
const autumn = new AutumnInt();
const autumn = new AutumnInt();
if (process.env.STRIPE_TEST_KEY) {
console.log(`Reconnecting stripe...`);
if (process.env.STRIPE_TEST_KEY) {
console.log(`Reconnecting stripe...`);
try {
await autumn.stripe.delete();
} catch (error) {}
try {
await autumn.stripe.connect({
secret_key: process.env.STRIPE_TEST_KEY!,
success_url: "https://useautumn.com",
default_currency: "usd",
});
} catch (error: any) {
console.error("Error reconnecting stripe", error.message);
process.exit(1);
}
}
try {
await autumn.stripe.delete();
} catch (error) {}
try {
await autumn.stripe.connect({
secret_key: process.env.STRIPE_TEST_KEY!,
success_url: "https://useautumn.com",
default_currency: "usd",
});
} catch (error: any) {
console.error("Error reconnecting stripe", error.message);
process.exit(1);
}
}
const { db, client } = initDrizzle();
const org = await OrgService.getBySlug({ db, slug: orgSlug });
const { db, client } = initDrizzle();
const org = await OrgService.getBySlug({ db, slug: orgSlug });
await Promise.all([
CacheManager.invalidate({
action: CacheType.SecretKey,
value: hashApiKey(process.env.UNIT_TEST_AUTUMN_SECRET_KEY!),
}),
CacheManager.invalidate({
action: CacheType.PublicKey,
value: process.env.UNIT_TEST_AUTUMN_PUBLIC_KEY!,
}),
]);
await CacheManager.disconnect();
await Promise.all([
CacheManager.invalidate({
action: CacheType.SecretKey,
value: hashApiKey(process.env.UNIT_TEST_AUTUMN_SECRET_KEY!),
}),
CacheManager.invalidate({
action: CacheType.PublicKey,
value: process.env.UNIT_TEST_AUTUMN_PUBLIC_KEY!,
}),
]);
await CacheManager.disconnect();
if (!org) {
throw new Error(`Org ${orgSlug} not found`);
}
if (!org) {
throw new Error(`Org ${orgSlug} not found`);
}
if (!(org.slug == "unit-test-org" || org.slug == "ci_cd")) {
console.error("Cannot clear non-unit-test-orgs");
process.exit(1);
}
if (!(org.slug === "unit-test-org" || org.slug === "ci_cd")) {
console.error("Cannot clear non-unit-test-orgs");
process.exit(1);
}
const orgId = org.id;
const orgId = org.id;
// 1. Delete all customers
await CusService.deleteByOrgId({ db, orgId, env });
console.log(" ✅ Deleted customers");
// 1. Delete all customers
await CusService.deleteByOrgId({ db, orgId, env });
console.log(" ✅ Deleted customers");
const stripeCli = createStripeCli({ org, env: env! });
const stripeCustomers = await stripeCli.customers.list({
limit: 100,
});
const deleteCustomer = async (customer: Stripe.Customer) => {
try {
await stripeCli.customers.del(customer.id);
} catch (error) {
console.error("Error deleting stripe customer", customer.id);
}
};
const stripeCli = createStripeCli({ org, env: env! });
const stripeCustomers = await stripeCli.customers.list({
limit: 100,
});
const deleteCustomer = async (customer: Stripe.Customer) => {
try {
await stripeCli.customers.del(customer.id);
} catch (error) {
console.error("Error deleting stripe customer", customer.id);
}
};
const cusBatchSize = 5;
for (let i = 0; i < stripeCustomers.data.length; i += cusBatchSize) {
const batch = stripeCustomers.data.slice(i, i + cusBatchSize);
const batchDeleteCustomers = [];
for (const customer of batch) {
batchDeleteCustomers.push(deleteCustomer(customer));
}
await Promise.all(batchDeleteCustomers);
console.log(
` ✅ Deleted ${i + batch.length}/${
stripeCustomers.data.length
} Stripe customers`
);
}
const cusBatchSize = 5;
for (let i = 0; i < stripeCustomers.data.length; i += cusBatchSize) {
const batch = stripeCustomers.data.slice(i, i + cusBatchSize);
const batchDeleteCustomers = [];
for (const customer of batch) {
batchDeleteCustomers.push(deleteCustomer(customer));
}
await Promise.all(batchDeleteCustomers);
console.log(
` ✅ Deleted ${i + batch.length}/${
stripeCustomers.data.length
} Stripe customers`,
);
}
console.log(" ✅ Deleted Stripe customers");
console.log(" ✅ Deleted Stripe customers");
// 2. Delete all products
await ProductService.deleteByOrgId({ db, orgId, env });
// 2. Delete all products
await ProductService.deleteByOrgId({ db, orgId, env });
console.log(" ✅ Deleted products");
console.log(" ✅ Deleted products");
const stripeProducts = await stripeCli.products.list({
limit: 100,
active: true,
});
const stripeProducts = await stripeCli.products.list({
limit: 100,
active: true,
});
const batchSize = 5;
const removeStripeProduct = async (product: Stripe.Product) => {
try {
await stripeCli.products.del(product.id);
} catch (error) {
await stripeCli.products.update(product.id, {
active: false,
});
}
};
const batchSize = 5;
const removeStripeProduct = async (product: Stripe.Product) => {
try {
await stripeCli.products.del(product.id);
} catch (error) {
await stripeCli.products.update(product.id, {
active: false,
});
}
};
for (let i = 0; i < stripeProducts.data.length; i += batchSize) {
const batch = stripeProducts.data.slice(i, i + batchSize);
const batchDeleteProducts = [];
for (const product of batch) {
batchDeleteProducts.push(removeStripeProduct(product));
}
await Promise.all(batchDeleteProducts);
// await timeout(800);
console.log(
` ✅ Deleted ${i + batch.length}/${
stripeProducts.data.length
} Stripe products`
);
}
for (let i = 0; i < stripeProducts.data.length; i += batchSize) {
const batch = stripeProducts.data.slice(i, i + batchSize);
const batchDeleteProducts = [];
for (const product of batch) {
batchDeleteProducts.push(removeStripeProduct(product));
}
await Promise.all(batchDeleteProducts);
// await timeout(800);
console.log(
` ✅ Deleted ${i + batch.length}/${
stripeProducts.data.length
} Stripe products`,
);
}
console.log(" ✅ Deleted Stripe products");
console.log(" ✅ Deleted Stripe products");
await deleteAllStripeTestClocks({ stripeCli });
console.log(" ✅ Deleted Stripe test clocks");
await deleteAllStripeTestClocks({ stripeCli });
console.log(" ✅ Deleted Stripe test clocks");
// Delete all stripe meters
await deactivateStripeMeters({ org, env });
console.log(" ✅ Deactivated Stripe meters");
// Delete all stripe meters
await deactivateStripeMeters({ org, env });
console.log(" ✅ Deactivated Stripe meters");
// Batch delete coupons
// Batch delete coupons
const batchDeleteCoupons = [];
await RewardService.deleteByOrgId({ db, orgId, env });
const batchDeleteCoupons = [];
await RewardService.deleteByOrgId({ db, orgId, env });
const stripeCoupons = await stripeCli.coupons.list({
limit: 100,
});
for (const coupon of stripeCoupons.data) {
batchDeleteCoupons.push(stripeCli.coupons.del(coupon.id));
}
const stripeCoupons = await stripeCli.coupons.list({
limit: 100,
});
for (const coupon of stripeCoupons.data) {
batchDeleteCoupons.push(stripeCli.coupons.del(coupon.id));
}
await Promise.all(batchDeleteCoupons);
console.log(" ✅ Deleted Stripe coupons");
await Promise.all(batchDeleteCoupons);
console.log(" ✅ Deleted Stripe coupons");
await FeatureService.deleteByOrgId({ db, orgId, env });
await FeatureService.deleteByOrgId({ db, orgId, env });
console.log(`✅ Cleared org ${orgSlug} (${env})`);
console.log(`✅ Cleared org ${orgSlug} (${env})`);
await client.end();
return org;
await client.end();
return org;
};
export const setupOrg = async ({
orgId,
env,
features,
products,
rewards,
rewardTriggers,
orgId,
env,
features,
products,
rewards,
rewardTriggers,
}: {
orgId: string;
env: AppEnv;
features: Record<string, Feature & { eventName: string }>;
products: Record<string, FullProduct | any>;
rewards: Record<string, any>;
rewardTriggers: Record<string, RewardProgram>;
orgId: string;
env: AppEnv;
features: Record<string, Feature & { eventName: string }>;
products: Record<string, FullProduct | any>;
rewards: Record<string, any>;
rewardTriggers: Record<string, RewardProgram>;
}) => {
const axiosInstance = getAxiosInstance();
const { client, db } = initDrizzle();
const axiosInstance = getAxiosInstance();
const { client, db } = initDrizzle();
const autumn = new AutumnInt();
const autumn = new AutumnInt();
let insertFeatures = [];
for (const feature of Object.values(features)) {
insertFeatures.push(axiosInstance.post("/v1/internal_features", feature));
}
const insertFeatures = [];
for (const feature of Object.values(features)) {
insertFeatures.push(axiosInstance.post("/v1/internal_features", feature));
}
await Promise.all(insertFeatures);
await Promise.all(insertFeatures);
await FeatureService.insert({
db,
data: Object.values(v2Features),
logger: console,
});
await FeatureService.insert({
db,
data: Object.values(v2Features),
logger: console,
});
// const allFeatures = await FeatureService.list({ db, orgId, env });
// const allFeatures = await FeatureService.list({ db, orgId, env });
let org: Organization | null = null;
let newFeatures: Feature[] = [];
try {
org = await OrgService.get({ db, orgId });
await OrgService.update({
db,
orgId,
updates: {
config: {
...org.config,
bill_upgrade_immediately: true,
},
},
});
let org: Organization | null = null;
let newFeatures: Feature[] = [];
try {
org = await OrgService.get({ db, orgId });
await OrgService.update({
db,
orgId,
updates: {
config: {
...org.config,
bill_upgrade_immediately: true,
},
},
});
newFeatures = (await FeatureService.list({ db, orgId, env })).filter((f) =>
Object.keys(features).includes(f.id)
);
} catch (error) {
console.error("Error updating org", error);
}
newFeatures = (await FeatureService.list({ db, orgId, env })).filter((f) =>
Object.keys(features).includes(f.id),
);
} catch (error) {
console.error("Error updating org", error);
}
for (const feature of newFeatures!) {
features[feature.id].internal_id = feature.internal_id;
for (const feature of newFeatures!) {
features[feature.id].internal_id = feature.internal_id;
if (feature.type === FeatureType.Metered) {
features[feature.id].eventName = feature.config?.filters[0].value[0];
}
}
if (feature.type === FeatureType.Metered) {
features[feature.id].eventName = feature.config?.filters[0].value[0];
}
}
console.log("✅ Inserted features");
console.log("✅ Inserted features");
// 2. Create products
let insertProducts = [];
// 2. Create products
const insertProducts = [];
const productValues = Object.values(products);
const batchSize = 5;
const productValues = Object.values(products);
const batchSize = 5;
for (
let batchStart = 0;
batchStart < productValues.length;
batchStart += batchSize
) {
const batch = productValues.slice(batchStart, batchStart + batchSize);
const batchPromises = [];
for (
let batchStart = 0;
batchStart < productValues.length;
batchStart += batchSize
) {
const batch = productValues.slice(batchStart, batchStart + batchSize);
const batchPromises = [];
for (const product of batch) {
const insertProduct = async () => {
await autumn.products.create({
id: product.id,
name: product.name,
group: product.group,
is_add_on: product.is_add_on,
is_default: product.is_default,
});
for (const product of batch) {
const insertProduct = async () => {
await autumn.products.create({
id: product.id,
name: product.name,
group: product.group,
is_add_on: product.is_add_on,
is_default: product.is_default,
});
const prices = product.prices.map((p: any) => ({
...p,
config: {
...p.config,
internal_feature_id: newFeatures!.find(
(f) => f.id === (p.config as any)?.feature_id
)?.internal_id,
},
}));
const prices = product.prices.map((p: any) => ({
...p,
config: {
...p.config,
internal_feature_id: newFeatures!.find(
(f) => f.id === (p.config as any)?.feature_id,
)?.internal_id,
},
}));
const entitlements = Object.values(product.entitlements).map(
(ent: any) => ({
...ent,
internal_feature_id: newFeatures!.find(
(f) => f.id === ent.feature_id
)?.internal_id,
})
);
const entitlements = Object.values(product.entitlements).map(
(ent: any) => ({
...ent,
internal_feature_id: newFeatures!.find(
(f) => f.id === ent.feature_id,
)?.internal_id,
}),
);
const entWithFeatures = entitlements.map((ent) => ({
...ent,
feature: newFeatures!.find((f) => f.id === ent.feature_id),
}));
const entWithFeatures = entitlements.map((ent) => ({
...ent,
feature: newFeatures!.find((f) => f.id === ent.feature_id),
}));
let items = mapToProductItems({
prices,
entitlements: entWithFeatures,
allowFeatureMatch: true,
features: newFeatures!,
});
const items = mapToProductItems({
prices,
entitlements: entWithFeatures,
allowFeatureMatch: true,
features: newFeatures!,
});
try {
await axiosInstance.post(`/v1/products/${product.id}`, {
// prices: prices,
// entitlements: entitlements,
items,
free_trial: product.free_trial,
});
} catch (error) {
console.log("Product:", product.name);
console.error("Error creating product prices / ents");
console.log("Items", items);
}
return;
};
try {
await axiosInstance.post(`/v1/products/${product.id}`, {
// prices: prices,
// entitlements: entitlements,
items,
free_trial: product.free_trial,
});
} catch (error) {
console.log("Product:", product.name);
console.error("Error creating product prices / ents");
console.log("Items", items);
}
return;
};
batchPromises.push(insertProduct());
}
batchPromises.push(insertProduct());
}
// Wait for the current batch to complete before proceeding to the next
await Promise.all(batchPromises);
insertProducts.push(...batchPromises);
}
// Wait for the current batch to complete before proceeding to the next
await Promise.all(batchPromises);
insertProducts.push(...batchPromises);
}
await Promise.all(insertProducts);
console.log("✅ Inserted products");
await Promise.all(insertProducts);
console.log("✅ Inserted products");
if (process.env.MOCHA_PARALLEL === "true") {
console.log("MOCHA RUNNING IN PARALLEL");
await AutumnCli.initStripeProducts();
console.log("✅ Initialized stripe products / prices");
} else {
console.log("MOCHA RUNNING IN SERIAL");
}
if (process.env.MOCHA_PARALLEL === "true") {
console.log("MOCHA RUNNING IN PARALLEL");
await AutumnCli.initStripeProducts();
console.log("✅ Initialized stripe products / prices");
} else {
console.log("MOCHA RUNNING IN SERIAL");
}
// return;
// return;
// Fetch all products
const { list: allProducts } = await AutumnCli.getProducts();
const productIds = allProducts.map((p: any) => p.id);
// Fetch all products
const { list: allProducts } = await AutumnCli.getProducts();
const productIds = allProducts.map((p: any) => p.id);
// Insert coupons
let insertCoupons = [];
for (const reward of Object.values(rewards)) {
const createReward = async () => {
let priceIds = [];
// Insert coupons
const insertCoupons = [];
for (const reward of Object.values(rewards)) {
const createReward = async () => {
let priceIds = [];
let rewardData: any = {
id: reward.id,
name: reward.name,
promo_codes: [
{
code: reward.id,
},
],
type: reward.type,
};
const rewardData: any = {
id: reward.id,
name: reward.name,
promo_codes: [
{
code: reward.id,
},
],
type: reward.type,
};
if (reward.type === RewardType.FreeProduct) {
rewardData.free_product_id = reward.free_product_id;
} else {
if (reward.only_usage_prices) {
let filteredProducts = allProducts.filter((product: FullProduct) => {
if (reward.product_ids) {
return reward.product_ids.includes(product.id);
} else return true;
});
if (reward.type === RewardType.FreeProduct) {
rewardData.free_product_id = reward.free_product_id;
rewardData.free_product_config = reward.free_product_config;
} else {
if (reward.only_usage_prices) {
const filteredProducts = allProducts.filter(
(product: FullProduct) => {
if (reward.product_ids) {
return reward.product_ids.includes(product.id);
} else return true;
},
);
priceIds = filteredProducts.flatMap((product: FullProduct) =>
product.prices
.filter((price: Price) => price.config!.type === PriceType.Usage)
.map((price) => {
return price.id;
})
);
} else if (reward.product_ids) {
priceIds = allProducts
.filter((product: FullProduct) =>
reward.product_ids.includes(product.id)
)
.flatMap((product: FullProduct) =>
product.prices.map((price) => price.id)
);
}
priceIds = filteredProducts.flatMap((product: FullProduct) =>
product.prices
.filter((price: Price) => price.config!.type === PriceType.Usage)
.map((price) => {
return price.id;
}),
);
} else if (reward.product_ids) {
priceIds = allProducts
.filter((product: FullProduct) =>
reward.product_ids.includes(product.id),
)
.flatMap((product: FullProduct) =>
product.prices.map((price) => price.id),
);
}
rewardData.discount_config = {
discount_value: reward.discount_config.discount_value,
duration_type: reward.discount_config.duration_type,
duration_value: reward.discount_config.duration_value,
apply_to_all: reward.discount_config.apply_to_all,
price_ids: priceIds,
};
}
rewardData.discount_config = {
discount_value: reward.discount_config.discount_value,
duration_type: reward.discount_config.duration_type,
duration_value: reward.discount_config.duration_value,
apply_to_all: reward.discount_config.apply_to_all,
price_ids: priceIds,
};
}
const newReward: CreateReward & { internal_id: string } = {
internal_id: reward.id,
id: reward.id,
name: reward.name,
promo_codes: [
{
code: reward.id,
},
],
type: reward.type,
discount_config: rewardData.discount_config,
free_product_id: rewardData.free_product_id,
};
const newReward: CreateReward & { internal_id: string } = {
internal_id: reward.id,
id: reward.id,
name: reward.name,
promo_codes: [
{
code: reward.id,
},
],
type: reward.type,
discount_config: rewardData.discount_config,
free_product_id: rewardData.free_product_id,
free_product_config: rewardData.free_product_config?.duration_type && rewardData.free_product_config?.duration_value ? rewardData.free_product_config : undefined,
};
let rewardRes = await autumn.rewards.create(newReward);
const rewardRes = await autumn.rewards.create(newReward);
return {
id: reward.id,
rewardRes,
};
};
return {
id: reward.id,
rewardRes,
};
};
console.log("Creating reward", reward.id);
insertCoupons.push(createReward());
}
console.log("Creating reward", reward.id);
insertCoupons.push(createReward());
}
await Promise.all(insertCoupons);
console.log("✅ Inserted coupons");
await Promise.all(insertCoupons);
console.log("✅ Inserted coupons");
// CREATE REWARD TRIGGERS
let insertRewardTriggers = [];
let insertedRewards = await RewardService.list({ db, orgId, env });
for (const rewardTrigger of Object.values(rewardTriggers)) {
let rt = {
...rewardTrigger,
internal_reward_id: insertedRewards.find(
(r) => r.id === rewardTrigger.internal_reward_id
)?.internal_id!,
};
insertRewardTriggers.push(autumn.rewardPrograms.create(rt));
}
await Promise.all(insertRewardTriggers);
console.log("✅ Inserted reward triggers");
// CREATE REWARD TRIGGERS
const insertRewardTriggers = [];
const insertedRewards = await RewardService.list({ db, orgId, env });
for (const rewardTrigger of Object.values(rewardTriggers)) {
const rt = {
...rewardTrigger,
internal_reward_id: insertedRewards.find(
(r) => r.id === rewardTrigger.internal_reward_id,
)?.internal_id!,
};
insertRewardTriggers.push(autumn.rewardPrograms.create(rt));
}
await Promise.all(insertRewardTriggers);
console.log("✅ Inserted reward triggers");
await client.end();
await client.end();
};

View File

@@ -66,7 +66,6 @@ export const completeCheckoutForm = async (
await page.type("#billingPostalCode", "123456");
if (overrideQuantity) {
// console.log(" - Overriding quantity");
const quantityBtn = await page.$(".AdjustableQuantitySelector");
await quantityBtn?.evaluate((b: any) => (b as HTMLElement).click());
@@ -77,6 +76,7 @@ export const completeCheckoutForm = async (
const updateBtn = await page.$(".AdjustQuantityFooter-btn");
await updateBtn?.evaluate((b: any) => (b as HTMLElement).click());
await timeout(1000);
}
@@ -88,7 +88,6 @@ export const completeCheckoutForm = async (
await timeout(5000);
}
// const submitButton = await page.$(".SubmitButton-TextContainer");
const submitButton = await page.$(".SubmitButton-TextContainer");
await submitButton?.evaluate((b: any) => (b as HTMLElement).click());
await timeout(7000);

View File

@@ -151,7 +151,7 @@ export const completeInvoiceCheckout = async ({
);
if (postalInput) {
await postalInput.click();
await postalInput.type("12345");
await postalInput.type("SW79SJ");
}
} catch (error) {
console.log("Could not find postal code input:", error);

View File

@@ -18,4 +18,5 @@ export interface AttachConfig {
sameIntervals: boolean;
carryTrial: boolean;
finalizeInvoice: boolean;
requirePaymentMethod: boolean;
}

View File

@@ -1,37 +1,38 @@
import { z } from "zod";
let ReferralCodeSchema = z.object({
// Code, org id, env should be unique
code: z.string(),
org_id: z.string(),
env: z.string(),
const ReferralCodeSchema = z.object({
// Code, org id, env should be unique
code: z.string(),
org_id: z.string(),
env: z.string(),
created_at: z.number(),
internal_customer_id: z.string(),
internal_reward_program_id: z.string(),
created_at: z.number(),
internal_customer_id: z.string(),
internal_reward_program_id: z.string(),
// ID of the referral code
id: z.string(),
// ID of the referral code
id: z.string(),
});
let RewardRedemptionSchema = z.object({
id: z.string(),
created_at: z.number(),
updated_at: z.number(),
const RewardRedemptionSchema = z.object({
id: z.string(),
created_at: z.number(),
updated_at: z.number(),
// Customer who signed up / paid
internal_customer_id: z.string(), // customer who redeemed the code...
internal_reward_program_id: z.string(), // reward scheme that was redeemed
// Customer who signed up / paid
internal_customer_id: z.string(), // customer who redeemed the code...
internal_reward_program_id: z.string(), // reward scheme that was redeemed
// Referral code used
// code: z.string(),
referral_code_id: z.string(),
// Referral code used
// code: z.string(),
referral_code_id: z.string(),
// Whether the reward was triggered
triggered: z.boolean(),
// Whether the reward was triggered
triggered: z.boolean(),
// Whether the (coupon) was applied
applied: z.boolean(),
// Whether the (coupon) was applied
applied: z.boolean(),
redeemer_applied: z.boolean(),
});
export type ReferralCode = z.infer<typeof ReferralCodeSchema>;

View File

@@ -19,6 +19,7 @@ export const rewardRedemptions = pgTable(
triggered: boolean(),
internal_reward_program_id: text("internal_reward_program_id"),
applied: boolean().default(false),
redeemer_applied: boolean().default(false),
referral_code_id: text("referral_code_id"),
},
(table) => [
@@ -37,5 +38,5 @@ export const rewardRedemptions = pgTable(
foreignColumns: [referralCodes.id],
name: "reward_redemptions_referral_code_id_fkey",
}).onDelete("cascade"),
],
]
);

View File

@@ -14,6 +14,11 @@ export const DiscountConfigSchema = z.object({
price_ids: z.array(z.string()).optional(),
});
export const FreeProductConfigSchema = z.object({
duration_type: z.nativeEnum(CouponDurationType),
duration_value: z.number(),
});
const RewardSchema = z.object({
name: z.string().nullish(),
@@ -23,6 +28,7 @@ const RewardSchema = z.object({
free_product_id: z.string().nullish(),
discount_config: DiscountConfigSchema.nullish(),
free_product_config: FreeProductConfigSchema.nullish(),
internal_id: z.string(),
org_id: z.string(),
@@ -36,6 +42,7 @@ export const CreateRewardSchema = z.object({
id: z.string(),
type: z.nativeEnum(RewardType).nullish(),
discount_config: DiscountConfigSchema.nullish(),
free_product_config: FreeProductConfigSchema.nullish(),
free_product_id: z.string().nullish(),
});
@@ -43,3 +50,4 @@ export type PromoCode = z.infer<typeof PromoCodeSchema>;
export type CreateReward = z.infer<typeof CreateRewardSchema>;
export type Reward = z.infer<typeof RewardSchema>;
export type DiscountConfig = z.infer<typeof DiscountConfigSchema>;
export type FreeProductConfig = z.infer<typeof FreeProductConfigSchema>;

View File

@@ -1,32 +1,37 @@
import { foreignKey, jsonb, text } from "drizzle-orm/pg-core";
import { numeric } from "drizzle-orm/pg-core";
import { pgTable } from "drizzle-orm/pg-core";
import type { InferInsertModel, InferSelectModel } from "drizzle-orm";
import { foreignKey, jsonb, numeric, pgTable, text } from "drizzle-orm/pg-core";
import { organizations } from "../../orgModels/orgTable.js";
import { DiscountConfig, PromoCode } from "./rewardModels.js";
import { InferSelectModel, InferInsertModel } from "drizzle-orm";
import type {
DiscountConfig,
FreeProductConfig,
PromoCode,
} from "./rewardModels.js";
export const rewards = pgTable(
"rewards",
{
internal_id: text("internal_id").primaryKey().notNull(),
id: text(),
org_id: text("org_id"),
env: text(),
created_at: numeric({ mode: "number" }),
name: text(),
discount_config: jsonb("discount_config").$type<DiscountConfig>(),
free_product_id: text("free_product_id"),
promo_codes: jsonb("promo_codes").$type<PromoCode[]>().array(),
type: text(),
},
(table) => [
foreignKey({
columns: [table.org_id],
foreignColumns: [organizations.id],
name: "coupons_org_id_fkey",
}).onDelete("cascade"),
],
"rewards",
{
internal_id: text("internal_id").primaryKey().notNull(),
id: text(),
org_id: text("org_id"),
env: text(),
created_at: numeric({ mode: "number" }),
name: text(),
discount_config: jsonb("discount_config").$type<DiscountConfig>(),
free_product_config: jsonb(
"free_product_config",
).$type<FreeProductConfig>(),
free_product_id: text("free_product_id"),
promo_codes: jsonb("promo_codes").$type<PromoCode[]>().array(),
type: text(),
},
(table) => [
foreignKey({
columns: [table.org_id],
foreignColumns: [organizations.id],
name: "coupons_org_id_fkey",
}).onDelete("cascade"),
],
);
export type RewardRow = InferSelectModel<typeof rewards>;
export type InsertRewardRow = InferInsertModel<typeof rewards>;
export type InsertRewardRow = InferInsertModel<typeof rewards>;

View File

@@ -14,9 +14,9 @@
"license": "Apache-2.0",
"scripts": {
"build:tsc": "tsc",
"build": "bun build ./index.ts --outdir dist --target bun --external zod",
"build": "bun build ./index.ts --outdir dist --format esm --target bun --external zod",
"dev": "bunx nodemon --ext ts --ignore dist --exec \"bun run build && bun run dev:dts\"",
"dev:dts": "tsc --emitDeclarationOnly --outDir dist --project tsconfig.json --declaration --declarationMap false --emitDeclarationOnly true --outFile dist/index.d.ts",
"dev:dts": "tsc --emitDeclarationOnly --outDir dist --project tsconfig.json",
"dev:bun": "bun ./index.ts --outdir dist --target bun --external zod --watch",
"db:push": "bun db:generate && bun db:migrate",
"db:generate": "cross-env NODE_OPTIONS=\"--import tsx\" bunx drizzle-kit generate --config drizzle.config.ts",

View File

@@ -20,3 +20,4 @@ export * from "./productV2Utils/mapToProductV2.js";
// Product utils
export * from "./productUtils/convertUtils.js";
export * from "./productUtils/priceUtils.js";

View File

@@ -17,7 +17,10 @@
"linter": {
"enabled": true,
"rules": {
"recommended": true
"recommended": true,
"complexity": {
"noStaticOnlyClass": "off"
}
}
},
"javascript": {

View File

@@ -1,21 +1,42 @@
import type React from "react";
import { cn } from "@/lib/utils";
import React from "react";
import { InfoTooltip } from "./InfoTooltip";
function FieldLabel({ children, className, description, empty = false }: any) {
if (empty) {
children = "\u00A0";
}
if (!description) {
return (
<div className={cn("text-t3 text-sm mb-2", className)}>{children}</div>
);
}
return (
<div className={cn("text-t3 text-sm mb-2", className)}>
{children}
{description && <p className="text-t3 text-xs">{description}</p>}
</div>
);
function FieldLabel({
children,
className,
description,
empty = false,
tooltip,
}: {
children: React.ReactNode;
className?: string;
description?: string;
empty?: boolean;
tooltip?: string;
}) {
if (empty) {
children = "\u00A0";
}
if (!description) {
return (
<div className={cn("text-t3 text-sm mb-2", className)}>{children}</div>
);
}
return (
<div className={cn("text-t3 text-sm mb-2", className)}>
{children}
{description && !tooltip && (
<p className="text-t3 text-xs">{description}</p>
)}
{tooltip && description && (
<div className="flex items-center gap-2">
<p className="text-t3 text-xs">{description}</p>
<InfoTooltip>{tooltip}</InfoTooltip>
</div>
)}
</div>
);
}
export default FieldLabel;

View File

@@ -1,37 +1,37 @@
import { Reward, CreateReward } from "@autumn/shared";
import type { CreateReward, Reward } from "@autumn/shared";
import { AxiosInstance } from "axios";
import type { AxiosInstance } from "axios";
export class RewardService {
static async createReward({
axiosInstance,
data,
}: {
axiosInstance: AxiosInstance;
data: CreateReward;
}) {
await axiosInstance.post("/v1/rewards", data);
}
static async createReward({
axiosInstance,
data,
}: {
axiosInstance: AxiosInstance;
data: CreateReward;
}) {
await axiosInstance.post("/v1/rewards", data);
}
static async deleteReward({
axiosInstance,
internalId,
}: {
axiosInstance: AxiosInstance;
internalId: string;
}) {
await axiosInstance.delete(`/v1/rewards/${internalId}`);
}
static async deleteReward({
axiosInstance,
internalId,
}: {
axiosInstance: AxiosInstance;
internalId: string;
}) {
await axiosInstance.delete(`/v1/rewards/${internalId}`);
}
static async updateReward({
axiosInstance,
internalId,
data,
}: {
axiosInstance: AxiosInstance;
internalId: string;
data: Reward;
}) {
await axiosInstance.post(`/v1/rewards/${internalId}`, data);
}
static async updateReward({
axiosInstance,
internalId,
data,
}: {
axiosInstance: AxiosInstance;
internalId: string;
data: Reward;
}) {
await axiosInstance.post(`/v1/rewards/${internalId}`, data);
}
}

View File

@@ -1,10 +1,12 @@
import {
BillingInterval,
EntInterval,
ProductItemInterval,
EntInterval
} from "@autumn/shared";
export const keyToTitle = (key: string) => {
export const keyToTitle = (key: string, options?: { exclusionMap?: Record<string, string> }) => {
if(options?.exclusionMap?.[key]) {
return options.exclusionMap[key];
}
return key
.replace(/[_-]/g, " ")
.replace(/\b\w/g, (char) => char.toUpperCase());
@@ -23,7 +25,7 @@ export const slugify = (
) => {
return text
.toLowerCase()
.replace(/ /g, type == "underscore" ? "_" : "-")
.replace(/ /g, type === "underscore" ? "_" : "-")
.replace(/[^\w\s-]/g, "");
};

View File

@@ -1,9 +1,9 @@
import {
BillingInterval,
EntInterval,
Feature,
type Feature,
Infinite,
ProductItem,
type ProductItem,
ProductItemFeatureType,
ProductItemType,
UsageModel,
@@ -13,103 +13,105 @@ import { isFeatureItem, isFeaturePriceItem, isPriceItem } from "./getItemType";
import { itemToUsageType } from "./productItemUtils/convertItem";
export const itemIsUnlimited = (item: ProductItem) => {
return item.included_usage == Infinite;
return item.included_usage === Infinite;
};
export const formatAmount = ({
defaultCurrency,
amount,
maxFractionDigits = 6,
defaultCurrency,
amount,
maxFractionDigits = 6,
}: {
defaultCurrency: string;
amount: number;
maxFractionDigits?: number;
defaultCurrency: string;
amount: number;
maxFractionDigits?: number;
}) => {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: defaultCurrency,
minimumFractionDigits: 0,
maximumFractionDigits: maxFractionDigits || 6,
}).format(amount);
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: defaultCurrency,
minimumFractionDigits: 0,
maximumFractionDigits: maxFractionDigits || 6,
}).format(amount);
};
export const getItemType = (item: ProductItem) => {
if (isPriceItem(item)) {
return ProductItemType.Price;
} else if (isFeatureItem(item)) {
return ProductItemType.Feature;
}
if (isPriceItem(item)) {
return ProductItemType.Price;
} else if (isFeatureItem(item)) {
return ProductItemType.Feature;
}
return ProductItemType.FeaturePrice;
return ProductItemType.FeaturePrice;
};
export const intervalIsNone = (interval: any) => {
return (
nullish(interval) ||
interval == EntInterval.Lifetime ||
interval == BillingInterval.OneOff
);
export const intervalIsNone = (
interval: EntInterval | BillingInterval | null | undefined,
) => {
return (
nullish(interval) ||
interval === EntInterval.Lifetime ||
interval === BillingInterval.OneOff
);
};
export const getShowParams = (item: ProductItem | null) => {
if (!item) {
return {
price: false,
feature: false,
allowance: false,
perEntity: false,
cycle: false,
};
}
if (!item) {
return {
price: false,
feature: false,
allowance: false,
perEntity: false,
cycle: false,
};
}
return {
price: notNullish(item.price) || notNullish(item.tiers),
feature: !isPriceItem(item),
allowance: true,
perEntity: notNullish(item.entity_feature_id),
cycle: true,
};
return {
price: notNullish(item.price) || notNullish(item.tiers),
feature: !isPriceItem(item),
allowance: true,
perEntity: notNullish(item.entity_feature_id),
cycle: true,
};
};
export const shouldShowProrationConfig = ({
item,
features,
item,
features,
}: {
item: ProductItem;
features: Feature[];
item: ProductItem;
features: Feature[];
}) => {
if (!isFeaturePriceItem(item)) return false;
if (!isFeaturePriceItem(item)) return false;
// If pay per use single use
const usageType = itemToUsageType({ item, features });
// If pay per use single use
const usageType = itemToUsageType({ item, features });
if (item.usage_model == UsageModel.Prepaid) return true;
if (item.usage_model === UsageModel.Prepaid) return true;
// if (
// usageType == ProductItemFeatureType.SingleUse &&
// item.usage_model == UsageModel.Prepaid
// ) {
// return true;
// } else
// if (
// usageType == ProductItemFeatureType.SingleUse &&
// item.usage_model == UsageModel.Prepaid
// ) {
// return true;
// } else
if (
usageType == ProductItemFeatureType.ContinuousUse
// &&item.usage_model !== UsageModel.Prepaid
) {
return true;
}
return false;
if (
usageType === ProductItemFeatureType.ContinuousUse
// &&item.usage_model !== UsageModel.Prepaid
) {
return true;
}
return false;
};
export const itemsHaveSameInterval = ({
item1,
item2,
item1,
item2,
}: {
item1: ProductItem;
item2: ProductItem;
item1: ProductItem;
item2: ProductItem;
}) => {
return (
item1.interval == item2.interval &&
(item1.interval_count || 1) == (item2.interval_count || 1)
);
return (
item1.interval === item2.interval &&
(item1.interval_count || 1) === (item2.interval_count || 1)
);
};

View File

@@ -1,68 +1,66 @@
import { CusProductStatus, type FullCusProduct } from "@autumn/shared";
import { differenceInDays } from "date-fns";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
import { CusProductStatus, FullCusProduct } from "@autumn/shared";
import { CusProductStripeLink } from "./CusProductStripeLink";
import { keyToTitle } from "@/utils/formatUtils/formatTextUtils";
import { formatUnixToDateTime } from "@/utils/formatUtils/formatDateUtils";
import { notNullish } from "@/utils/genUtils";
import { differenceInDays, subDays } from "date-fns";
import { CusProductStripeLink } from "./CusProductStripeLink";
export const CusProductStatusItem = ({
cusProduct,
cusProduct,
}: {
cusProduct: FullCusProduct;
cusProduct: FullCusProduct;
}) => {
const getStatus = () => {
if (cusProduct.status == CusProductStatus.Expired) {
return CusProductStatus.Expired;
}
const getStatus = () => {
if (cusProduct.status == CusProductStatus.Expired) {
return CusProductStatus.Expired;
}
const trialing =
cusProduct.trial_ends_at && cusProduct.trial_ends_at > Date.now();
const trialing =
cusProduct.trial_ends_at && cusProduct.trial_ends_at > Date.now();
const canceled = cusProduct.canceled;
const canceled = cusProduct.canceled;
if (canceled) return "canceled";
if (canceled) return "canceled";
if (trialing) {
return CusProductStatus.Trialing;
}
if (trialing) {
return CusProductStatus.Trialing;
}
return cusProduct.status;
};
return cusProduct.status;
};
const getTitle = () => {
const status = getStatus();
if (status == CusProductStatus.Trialing) {
const daysTillEnd = differenceInDays(
new Date(cusProduct.trial_ends_at!),
new Date()
);
return `trial (${daysTillEnd}d)`;
}
return keyToTitle(getStatus()).toLowerCase();
};
const getTitle = () => {
const status = getStatus();
if (status === CusProductStatus.Trialing) {
const daysTillEnd = differenceInDays(
new Date(cusProduct.trial_ends_at!),
new Date(),
);
return `trial (${daysTillEnd}d)`;
}
return keyToTitle(getStatus()).toLowerCase();
};
const statusToColor: Record<CusProductStatus | "canceled", string> = {
[CusProductStatus.Active]: "bg-lime-500",
[CusProductStatus.Expired]: "bg-stone-800",
[CusProductStatus.PastDue]: "bg-red-500",
[CusProductStatus.Scheduled]: "bg-blue-500",
[CusProductStatus.Trialing]: "bg-blue-400",
canceled: "bg-gray-500",
[CusProductStatus.Unknown]: "bg-gray-500",
};
const statusToColor: Record<CusProductStatus | "canceled", string> = {
[CusProductStatus.Active]: "bg-lime-500",
[CusProductStatus.Expired]: "bg-stone-800",
[CusProductStatus.PastDue]: "bg-red-500",
[CusProductStatus.Scheduled]: "bg-blue-500",
[CusProductStatus.Trialing]: "bg-blue-400",
canceled: "bg-gray-500",
[CusProductStatus.Unknown]: "bg-gray-500",
};
return (
<div className="flex gap-0.5 items-center">
<Badge
variant="status"
className={cn("h-fit", statusToColor[getStatus()])}
>
{getTitle()}
</Badge>
return (
<div className="flex gap-0.5 items-center">
<Badge
variant="status"
className={cn("h-fit", statusToColor[getStatus()])}
>
{getTitle()}
</Badge>
<CusProductStripeLink cusProduct={cusProduct} />
</div>
);
<CusProductStripeLink cusProduct={cusProduct} />
</div>
);
};

View File

@@ -1,83 +1,83 @@
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogTrigger,
DialogHeader,
DialogTitle,
DialogFooter,
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import React, { useEffect, useState } from "react";
import { useAxiosInstance } from "@/services/useAxiosInstance";
import { toast } from "sonner";
import { getBackendErr } from "@/utils/genUtils";
import { useProductsContext } from "../../ProductsContext";
import { RewardService } from "@/services/products/RewardService";
import { RewardConfig } from "./RewardConfig";
import { defaultReward } from "../utils/defaultRewardModels";
import { useRewardsQuery } from "@/hooks/queries/useRewardsQuery";
import { RewardService } from "@/services/products/RewardService";
import { useAxiosInstance } from "@/services/useAxiosInstance";
import { getBackendErr } from "@/utils/genUtils";
import { defaultReward } from "../utils/defaultRewardModels";
import { RewardConfig } from "./RewardConfig";
function CreateReward() {
const axiosInstance = useAxiosInstance();
const { refetch } = useRewardsQuery();
const axiosInstance = useAxiosInstance();
const { refetch } = useRewardsQuery();
const [isLoading, setIsLoading] = useState(false);
const [open, setOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [open, setOpen] = useState(false);
const [reward, setReward] = useState(defaultReward);
const [reward, setReward] = useState(defaultReward);
useEffect(() => {
if (open) {
setReward(defaultReward);
}
}, [open]);
useEffect(() => {
if (open) {
setReward(defaultReward);
}
}, [open]);
const handleCreate = async () => {
if (!reward?.id && !reward?.name) {
toast.error("ID and name are required");
return;
}
setIsLoading(true);
try {
await RewardService.createReward({
axiosInstance,
data: reward,
});
const handleCreate = () => {
setIsLoading(true);
(async () => {
if (!reward?.id && !reward?.name) {
toast.error("ID and name are required");
return;
}
await refetch();
setOpen(false);
} catch (error) {
console.log("Error:", error);
toast.error(getBackendErr(error, "Failed to create coupon"));
}
setIsLoading(false);
};
try {
await RewardService.createReward({
axiosInstance,
data: reward,
});
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="add"> Reward</Button>
</DialogTrigger>
<DialogContent className="w-[500px]">
<DialogHeader>
<DialogTitle>Create Reward</DialogTitle>
</DialogHeader>
<RewardConfig reward={reward as any} setReward={setReward as any} />
<DialogFooter>
<Button
onClick={handleCreate}
isLoading={isLoading}
variant="gradientPrimary"
>
Create
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
await refetch();
setOpen(false);
} catch (error) {
console.log("Error:", error);
toast.error(getBackendErr(error, "Failed to create coupon"));
} finally {
setIsLoading(false);
}
})();
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="add"> Reward</Button>
</DialogTrigger>
<DialogContent className="w-[500px]">
<DialogHeader>
<DialogTitle>Create Reward</DialogTitle>
</DialogHeader>
<RewardConfig reward={reward as any} setReward={setReward as any} />
<DialogFooter>
<Button
onClick={handleCreate}
isLoading={isLoading}
variant="gradientPrimary"
>
Create
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export default CreateReward;

View File

@@ -1,20 +1,13 @@
import FieldLabel from "@/components/general/modal-components/FieldLabel";
import { Input } from "@/components/ui/input";
import { useState } from "react";
import { Select, SelectContent, SelectItem } from "@/components/ui/select";
import { SelectTrigger, SelectValue } from "@/components/ui/select";
import { keyToTitle } from "@/utils/formatUtils/formatTextUtils";
import {
Reward,
CouponDurationType,
type ProductItem,
type Reward,
RewardType,
ProductItem,
} from "@autumn/shared";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Check, ChevronsUpDown, X } from "lucide-react";
import { useState } from "react";
import FieldLabel from "@/components/general/modal-components/FieldLabel";
import { Button } from "@/components/ui/button";
import {
Command,
CommandEmpty,
@@ -23,104 +16,115 @@ import {
CommandItem,
CommandList,
} from "@/components/ui/command";
import { Check, ChevronsUpDown, Trash2, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useOrg } from "@/hooks/common/useOrg";
import { useFeaturesQuery } from "@/hooks/queries/useFeaturesQuery";
import { useProductsQuery } from "@/hooks/queries/useProductsQuery";
import { keyToTitle } from "@/utils/formatUtils/formatTextUtils";
import { isFeatureItem } from "@/utils/product/getItemType";
import { formatProductItemText } from "@/utils/product/product-item/formatProductItem";
import { useOrg } from "@/hooks/common/useOrg";
import { useProductsQuery } from "@/hooks/queries/useProductsQuery";
import { useFeaturesQuery } from "@/hooks/queries/useFeaturesQuery";
export const DiscountConfig = ({
reward,
setReward,
reward,
setReward,
}: {
reward: Reward;
setReward: (reward: Reward) => void;
reward: Reward;
setReward: (reward: Reward) => void;
}) => {
const { org } = useOrg();
const { org } = useOrg();
const config = reward.discount_config!;
const setConfig = (key: any, value: any) => {
setReward({
...reward,
discount_config: { ...config, [key]: value },
});
};
const config = reward.discount_config!;
const setConfig = (key: any, value: any) => {
setReward({
...reward,
discount_config: { ...config, [key]: value },
});
};
return (
<div className="flex flex-col gap-4 w-full">
<div className="flex items-center gap-2">
<div className="w-6/12">
<FieldLabel>Amount</FieldLabel>
<Input
value={config.discount_value}
onChange={(e) =>
setConfig("discount_value", Number(e.target.value))
}
endContent={
<p className="text-t3">
{reward.type === RewardType.PercentageDiscount
? "%"
: org?.default_currency || "USD"}
</p>
}
/>
</div>
<div className="w-6/12">
<FieldLabel>Duration</FieldLabel>
<div className="flex items-center gap-1">
{config.duration_type === CouponDurationType.Months && (
<Input
className="w-[60px] no-spinner"
value={config.duration_value}
onChange={(e) => {
setConfig("duration_value", Number(e.target.value));
}}
type="number"
/>
)}
<Select
value={config.duration_type}
onValueChange={(value) =>
setConfig("duration_type", value as CouponDurationType)
}
>
<SelectTrigger>
<SelectValue placeholder="Select a duration" />
</SelectTrigger>
<SelectContent>
{Object.values(CouponDurationType)
.filter((type) => {
if (
reward.type == RewardType.FixedDiscount &&
type == CouponDurationType.Forever &&
config.duration_type !== CouponDurationType.Forever
) {
return false;
}
if (
reward.type == RewardType.InvoiceCredits &&
type == CouponDurationType.OneOff
) {
return false;
}
return true;
})
.map((type) => (
<SelectItem key={type} value={type}>
{keyToTitle(type)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
return (
<div className="flex flex-col gap-4 w-full">
<div className="flex items-center gap-2">
<div className="w-6/12">
<FieldLabel>Amount</FieldLabel>
<Input
value={config.discount_value}
onChange={(e) =>
setConfig("discount_value", Number(e.target.value))
}
endContent={
<p className="text-t3">
{reward.type === RewardType.PercentageDiscount
? "%"
: org?.default_currency || "USD"}
</p>
}
/>
</div>
<div className="w-6/12">
<FieldLabel>Duration</FieldLabel>
<div className="flex items-center gap-1">
{config.duration_type === CouponDurationType.Months && (
<Input
className="w-[60px] no-spinner"
value={config.duration_value}
onChange={(e) => {
setConfig("duration_value", Number(e.target.value));
}}
type="number"
/>
)}
<Select
value={config.duration_type}
onValueChange={(value) =>
setConfig("duration_type", value as CouponDurationType)
}
>
<SelectTrigger>
<SelectValue placeholder="Select a duration" />
</SelectTrigger>
<SelectContent>
{Object.values(CouponDurationType)
.filter((type) => {
if (
reward.type === RewardType.FixedDiscount &&
type === CouponDurationType.Forever &&
config.duration_type !== CouponDurationType.Forever
) {
return false;
}
if (
reward.type === RewardType.InvoiceCredits &&
type === CouponDurationType.OneOff
) {
return false;
}
return true;
})
.map((type) => (
<SelectItem key={type} value={type}>
{keyToTitle(type)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
{/* {config.duration_type !== CouponDurationType.OneOff &&
{/* {config.duration_type !== CouponDurationType.OneOff &&
reward.type === RewardType.FixedDiscount && (
<div className="w-full ml-1 flex items-center gap-2">
<Checkbox
@@ -133,164 +137,164 @@ export const DiscountConfig = ({
</div>
)} */}
<div className="">
{/* <p className="text-t2 mb-2 text-t3">Products</p> */}
<FieldLabel>Products</FieldLabel>
<div className="">
{/* <p className="text-t2 mb-2 text-t3">Products</p> */}
<FieldLabel>Products</FieldLabel>
<ProductPriceSelector reward={reward} setReward={setReward} />
</div>
</div>
);
<ProductPriceSelector reward={reward} setReward={setReward} />
</div>
</div>
);
};
const ProductPriceSelector = ({
reward,
setReward,
reward,
setReward,
}: {
reward: Reward;
setReward: (reward: Reward) => void;
reward: Reward;
setReward: (reward: Reward) => void;
}) => {
const { org } = useOrg();
const { products } = useProductsQuery();
const { features } = useFeaturesQuery();
const { org } = useOrg();
const { products } = useProductsQuery();
const { features } = useFeaturesQuery();
const [open, setOpen] = useState(false);
const [open, setOpen] = useState(false);
const config = reward.discount_config!;
const setConfig = (key: any, value: any) => {
setReward({
...reward,
discount_config: { ...config, [key]: value },
});
};
const config = reward.discount_config!;
const setConfig = (key: any, value: any) => {
setReward({
...reward,
discount_config: { ...config, [key]: value },
});
};
// Handle selection/deselection of a price
const handlePriceToggle = (priceId: string) => {
let newPriceIds = [...(config.price_ids || [])];
if (config.price_ids?.includes(priceId)) {
newPriceIds = config.price_ids?.filter((id) => id !== priceId) || [];
} else {
newPriceIds = [...(config.price_ids || []), priceId];
}
setConfig("price_ids", newPriceIds);
};
// Handle selection/deselection of a price
const handlePriceToggle = (priceId: string) => {
let newPriceIds = [...(config.price_ids || [])];
if (config.price_ids?.includes(priceId)) {
newPriceIds = config.price_ids?.filter((id) => id !== priceId) || [];
} else {
newPriceIds = [...(config.price_ids || []), priceId];
}
setConfig("price_ids", newPriceIds);
};
if (!products || products.length === 0) {
return <p className="text-sm text-t3">No products available</p>;
}
if (!products || products.length === 0) {
return <p className="text-sm text-t3">No products available</p>;
}
return (
<Popover modal open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="w-full min-h-9 flex flex-wrap h-fit py-2 justify-start items-center gap-2 relative hover:bg-zinc-50"
>
{config.apply_to_all ? (
"All Products"
) : config.price_ids?.length == 0 ? (
"Select Products"
) : (
<>
{config.price_ids?.map((priceId) => {
const item = products
.find((p: any) =>
p.items.find((i: any) => i.price_id === priceId)
)
?.items.find((i: any) => i.price_id === priceId);
return (
<Popover modal open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="w-full min-h-9 flex flex-wrap h-fit py-2 justify-start items-center gap-2 relative hover:bg-zinc-50"
>
{config.apply_to_all ? (
"All Products"
) : config.price_ids?.length == 0 ? (
"Select Products"
) : (
<>
{config.price_ids?.map((priceId) => {
const item = products
.find((p: any) =>
p.items.find((i: any) => i.price_id === priceId),
)
?.items.find((i: any) => i.price_id === priceId);
const text = item
? formatProductItemText({
item,
org,
features,
})
: "Deleted price";
return (
<div
key={priceId}
className="py-1 px-3 text-xs text-t3 border-zinc-300 bg-zinc-100 rounded-full flex items-center gap-2 h-fit max-w-[200px] min-w-0"
>
<p className="truncate flex-1 min-w-0">{text}</p>
<Button
variant="ghost"
size="icon"
onClick={(e) => {
e.stopPropagation();
handlePriceToggle(priceId);
}}
className="bg-transparent hover:bg-transparent p-0 w-5 h-5"
>
<X size={12} className="text-t3" />
</Button>
</div>
);
})}
</>
)}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50 absolute right-2" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[400px] p-0" align="start">
<Command>
<CommandInput placeholder="Search prices..." className="h-9" />
<CommandList className="max-h-[300px] overflow-y-auto">
<ScrollArea>
<CommandEmpty>No prices found.</CommandEmpty>
<CommandGroup>
<CommandItem
onSelect={() => {
setConfig("apply_to_all", !config.apply_to_all);
}}
className="cursor-pointer"
>
<p>Apply to all products</p>
{config.apply_to_all && (
<Check size={12} className="text-t3" />
)}
</CommandItem>
</CommandGroup>
{!config.apply_to_all &&
products.map((product: any) => (
<CommandGroup key={product.id} heading={product.name}>
{product.items.length > 0 ? (
product.items
?.filter((item: ProductItem) => {
return !isFeatureItem(item);
})
.map((item: any) => (
<CommandItem
key={item.price_id}
value={item.price_id}
onSelect={() => handlePriceToggle(item.price_id)}
className="cursor-pointer overflow-x-hidden max-w-[380px]"
>
<span className="truncate overflow-x-hidden">
{formatProductItemText({
item,
org,
features,
})}
</span>
const text = item
? formatProductItemText({
item,
org,
features,
})
: "Deleted price";
return (
<div
key={priceId}
className="py-1 px-3 text-xs text-t3 border-zinc-300 bg-zinc-100 rounded-full flex items-center gap-2 h-fit max-w-[200px] min-w-0"
>
<p className="truncate flex-1 min-w-0">{text}</p>
<Button
variant="ghost"
size="icon"
onClick={(e) => {
e.stopPropagation();
handlePriceToggle(priceId);
}}
className="bg-transparent hover:bg-transparent p-0 w-5 h-5"
>
<X size={12} className="text-t3" />
</Button>
</div>
);
})}
</>
)}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50 absolute right-2" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[400px] p-0" align="start">
<Command>
<CommandInput placeholder="Search prices..." className="h-9" />
<CommandList className="max-h-[300px] overflow-y-auto">
<ScrollArea>
<CommandEmpty>No prices found.</CommandEmpty>
<CommandGroup>
<CommandItem
onSelect={() => {
setConfig("apply_to_all", !config.apply_to_all);
}}
className="cursor-pointer"
>
<p>Apply to all products</p>
{config.apply_to_all && (
<Check size={12} className="text-t3" />
)}
</CommandItem>
</CommandGroup>
{!config.apply_to_all &&
products.map((product: any) => (
<CommandGroup key={product.id} heading={product.name}>
{product.items.length > 0 ? (
product.items
?.filter((item: ProductItem) => {
return !isFeatureItem(item);
})
.map((item: any) => (
<CommandItem
key={item.price_id}
value={item.price_id}
onSelect={() => handlePriceToggle(item.price_id)}
className="cursor-pointer overflow-x-hidden max-w-[380px]"
>
<span className="truncate overflow-x-hidden">
{formatProductItemText({
item,
org,
features,
})}
</span>
{config.price_ids?.includes(item.price_id) && (
<Check size={12} className="text-t3" />
)}
</CommandItem>
))
) : (
<CommandItem disabled>
<p className="text-sm text-t3">No prices available</p>
</CommandItem>
)}
</CommandGroup>
))}
</ScrollArea>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
{config.price_ids?.includes(item.price_id) && (
<Check size={12} className="text-t3" />
)}
</CommandItem>
))
) : (
<CommandItem disabled>
<p className="text-sm text-t3">No prices available</p>
</CommandItem>
)}
</CommandGroup>
))}
</ScrollArea>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
};

View File

@@ -0,0 +1,73 @@
import { CouponDurationType, type Reward } from "@autumn/shared";
import FieldLabel from "@/components/general/modal-components/FieldLabel";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { keyToTitle } from "@/utils/formatUtils/formatTextUtils";
export const FreeDurationSelect = ({
reward,
setReward,
}: {
reward: Reward;
setReward: (reward: Reward) => void;
}) => {
return (
<div className="w-full">
<FieldLabel>Duration</FieldLabel>
<div className="flex items-center gap-2">
{
<Input
className="no-spinner"
value={Number(reward.free_product_config?.duration_value) || ""}
onChange={(e) => {
setReward({
...reward,
// @ts-expect-error
free_product_config: {
...(reward.free_product_config ?? {}),
duration_value: Number(e.target.value),
},
});
}}
type="number"
min={1}
max={12}
/>
}
<Select
value={reward.free_product_config?.duration_type || CouponDurationType.Months}
onValueChange={(value) =>
setReward({
...reward,
// @ts-expect-error
free_product_config: {
...(reward.free_product_config ?? {}),
duration_type: value as CouponDurationType,
},
})
}
>
<SelectTrigger>
<SelectValue placeholder="Select a duration" />
</SelectTrigger>
<SelectContent>
{Object.values(CouponDurationType)
.filter((x) => x !== CouponDurationType.Forever)
.filter((x) => x !== CouponDurationType.OneOff)
.map((type) => (
<SelectItem key={type} value={type}>
{keyToTitle(type)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
);
};

View File

@@ -1,146 +1,236 @@
import FieldLabel from "@/components/general/modal-components/FieldLabel";
import { Input } from "@/components/ui/input";
import {
type ProductV2,
type Reward,
RewardType,
UsageModel,
} from "@autumn/shared";
import { useEffect, useState } from "react";
import { Select, SelectContent, SelectItem } from "@/components/ui/select";
import { SelectTrigger, SelectValue } from "@/components/ui/select";
import { keyToTitle, slugify } from "@/utils/formatUtils/formatTextUtils";
import { Reward, RewardType, Product, ProductV2 } from "@autumn/shared";
import { useProductsContext } from "../../ProductsContext";
import { DiscountConfig } from "./DiscountConfig";
import { notNullish } from "@/utils/genUtils";
import { defaultDiscountConfig } from "../utils/defaultRewardModels";
import { isFreeProduct } from "@/utils/product/priceUtils";
import FieldLabel from "@/components/general/modal-components/FieldLabel";
import { WarningBox } from "@/components/general/modal-components/WarningBox";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useOrg } from "@/hooks/common/useOrg";
import { useProductsQuery } from "@/hooks/queries/useProductsQuery";
import {
formatAmount as formatCurrency,
keyToTitle,
slugify,
} from "@/utils/formatUtils/formatTextUtils";
import { notNullish } from "@/utils/genUtils";
import { isFeaturePriceItem, isPriceItem } from "@/utils/product/getItemType";
import { isFreeProduct, isOneOffProduct } from "@/utils/product/priceUtils";
import { defaultDiscountConfig } from "../utils/defaultRewardModels";
import { DiscountConfig } from "./DiscountConfig";
import { FreeDurationSelect } from "./FreeDurationSelect";
export const RewardConfig = ({
reward,
setReward,
reward,
setReward,
}: {
reward: Reward;
setReward: (reward: Reward) => void;
reward: Reward;
setReward: (reward: Reward) => void;
}) => {
const [idChanged, setIdChanged] = useState(false);
const { products } = useProductsQuery();
const [idChanged, setIdChanged] = useState(false);
const { products } = useProductsQuery();
const { org } = useOrg();
useEffect(() => {
if (!idChanged) {
setReward({
...reward,
id: slugify(reward.name || ""),
});
}
}, [reward.name]);
useEffect(() => {
if (!idChanged) {
setReward({
...reward,
id: slugify(reward.name || ""),
});
}
}, [reward, idChanged, setReward]);
return (
<div className="flex flex-col gap-4">
<div className="flex items-center gap-2">
<div className="w-6/12">
<FieldLabel description="Will be shown on receipt">Name</FieldLabel>
<Input
value={reward.name || ""}
onChange={(e) => setReward({ ...reward, name: e.target.value })}
/>
</div>
<div className="w-6/12">
<FieldLabel description="Used to identify reward in API">
ID
</FieldLabel>
<Input
value={reward.id || ""}
onChange={(e) => {
setReward({ ...reward, id: e.target.value });
setIdChanged(true);
}}
/>
</div>
</div>
<div className="flex items-center w-full gap-2">
<div className="w-full">
<FieldLabel>Promotional Code</FieldLabel>
<Input
value={
reward.promo_codes.length > 0 ? reward.promo_codes[0].code : ""
}
onChange={(e) =>
setReward({
...reward,
promo_codes: [{ code: e.target.value }],
})
}
/>
</div>
<div className="w-full">
<FieldLabel>Type</FieldLabel>
<Select
value={reward.type}
onValueChange={(value) => {
setReward({
...reward,
type: value as RewardType,
discount_config:
value === RewardType.FreeProduct
? null
: defaultDiscountConfig,
});
}}
>
<SelectTrigger>
<SelectValue placeholder="Select a discount type" />
</SelectTrigger>
<SelectContent>
{Object.values(RewardType).map((type) => (
<SelectItem key={type} value={type}>
{keyToTitle(type)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{reward.type === RewardType.FreeProduct ? (
<div>
<FieldLabel description="Select a free add-on product to give away">
Product
</FieldLabel>
<Select
value={reward.free_product_id || undefined}
onValueChange={(value) =>
setReward({ ...reward, free_product_id: value })
}
>
{(() => {
const freeAddOns = products
.filter((product: ProductV2) => product.is_add_on)
.filter((product: ProductV2) => isFreeProduct(product.items));
return (
<div className="flex flex-col gap-4">
<div className="flex items-center gap-2">
<div className="w-6/12">
<FieldLabel description="Will be shown on receipt">Name</FieldLabel>
<Input
value={reward.name || ""}
onChange={(e) => setReward({ ...reward, name: e.target.value })}
/>
</div>
<div className="w-6/12">
<FieldLabel description="Used to identify reward in API">
ID
</FieldLabel>
<Input
value={reward.id || ""}
onChange={(e) => {
setReward({ ...reward, id: e.target.value });
setIdChanged(true);
}}
/>
</div>
</div>
<div className="flex items-center w-full gap-2">
<div className="w-full">
<FieldLabel>Promotional Code</FieldLabel>
<Input
value={
reward.promo_codes.length > 0 ? reward.promo_codes[0].code : ""
}
onChange={(e) =>
setReward({
...reward,
promo_codes: [{ code: e.target.value }],
})
}
/>
</div>
<div className="w-full">
<FieldLabel>Type</FieldLabel>
<Select
value={reward.type}
onValueChange={(value) => {
setReward({
...reward,
type: value as RewardType,
discount_config:
value === RewardType.FreeProduct
? null
: defaultDiscountConfig,
});
}}
>
<SelectTrigger>
<SelectValue placeholder="Select a discount type" />
</SelectTrigger>
<SelectContent>
{Object.values(RewardType).map((type) => (
<SelectItem key={type} value={type}>
{keyToTitle(type)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{reward.type === RewardType.FreeProduct ? (
<div>
<div>
<FieldLabel
description="Select a product to give away"
tooltip="If the referrer/redeemer already has the product, it will not be added to them."
>
Product
</FieldLabel>
</div>
<Select
value={reward.free_product_id || undefined}
onValueChange={(value) =>
setReward({ ...reward, free_product_id: value })
}
>
{(() => {
const filteredProducts = [
// Paid products, no feature prices
...products
.filter((product: ProductV2) => !isFreeProduct(product.items))
.filter(
(product: ProductV2) =>
!product.items.some(
(x) =>
isFeaturePriceItem(x) &&
x.usage_model === UsageModel.Prepaid,
),
),
const empty = freeAddOns.length === 0;
return (
<>
<SelectTrigger disabled={empty}>
<SelectValue
placeholder={
empty
? "Create a free add-on product first"
: "Select a product"
}
/>
</SelectTrigger>
<SelectContent>
{freeAddOns.map((product: ProductV2) => (
<SelectItem key={product.id} value={product.id}>
{product.name}
</SelectItem>
))}
</SelectContent>
</>
);
})()}
</Select>
</div>
) : notNullish(reward.type) ? (
<DiscountConfig reward={reward} setReward={setReward} />
) : null}
</div>
);
// Free add-ons
...products
.filter((product: ProductV2) => product.is_add_on)
.filter((product: ProductV2) => isFreeProduct(product.items)),
];
const empty = filteredProducts.length === 0;
return (
<>
<SelectTrigger disabled={empty}>
<SelectValue
placeholder={
empty
? "Create a free add-on or paid product first"
: "Select a product"
}
/>
</SelectTrigger>
<SelectContent>
{filteredProducts.map((product: ProductV2) => (
<SelectItem key={product.id} value={product.id}>
{product.name}
</SelectItem>
))}
</SelectContent>
</>
);
})()}
</Select>
{(() => {
const selectedProduct = products.find(
(p: ProductV2) => p.id === reward.free_product_id,
);
if (!selectedProduct) return null;
const isPaidSelected = !isFreeProduct(selectedProduct.items);
if (!isPaidSelected) return null;
const isRecurringSelected = !isOneOffProduct(selectedProduct.items);
const hasUsagePrices = selectedProduct.items.some(
(x) =>
isFeaturePriceItem(x) && x.usage_model === UsageModel.PayPerUse,
);
const priceItem = selectedProduct.items.find((x) => isPriceItem(x));
const currency = org?.default_currency || "USD";
const fixedAmountStr = priceItem?.price
? formatCurrency({ amount: priceItem.price, currency })
: undefined;
if (isRecurringSelected) {
return (
<div className="mt-3">
<WarningBox>
Users will receive a coupon equal to this product's fixed
price amount.{" "}
{fixedAmountStr
? `If they're on a different tier, they will receive ${fixedAmountStr} off.`
: "If they're on a different tier, they will receive the fixed amount off."}{" "}
{hasUsagePrices
? "Charges due to usage prices will not be included in the coupon."
: ""}
</WarningBox>
</div>
);
}
return;
})()}
</div>
) : notNullish(reward.type) ? (
<DiscountConfig reward={reward} setReward={setReward} />
) : null}
{reward.type === RewardType.FreeProduct &&
notNullish(reward.free_product_id) &&
reward.free_product_id &&
!isOneOffProduct(
products.find(
(product: ProductV2) => product.id === reward.free_product_id,
)?.items || [],
) ? (
<FreeDurationSelect reward={reward} setReward={setReward} />
) : null}
</div>
);
};

View File

@@ -1,90 +1,92 @@
import type { CreateRewardProgram } from "@autumn/shared";
import { RewardReceivedBy, RewardTriggerEvent } from "@autumn/shared";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogTrigger,
DialogHeader,
DialogTitle,
DialogFooter,
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { useEffect, useState } from "react";
import { useAxiosInstance } from "@/services/useAxiosInstance";
import { toast } from "sonner";
import { RewardTriggerEvent, RewardReceivedBy } from "@autumn/shared";
import { getBackendErr } from "@/utils/genUtils";
import { useRewardsQuery } from "@/hooks/queries/useRewardsQuery";
import { CreateRewardProgram } from "@autumn/shared";
import { useAxiosInstance } from "@/services/useAxiosInstance";
import { getBackendErr } from "@/utils/genUtils";
import { RewardProgramConfig } from "./RewardProgramConfig";
const defaultRewardProgram: CreateRewardProgram = {
id: "",
// trigger: {
// type: RewardTriggerEvent.SignUp,
// product_ids: [],
// exclude_trial: false,
// },
when: RewardTriggerEvent.CustomerCreation,
product_ids: [],
exclude_trial: false,
internal_reward_id: "",
max_redemptions: 0,
received_by: RewardReceivedBy.Referrer,
id: "",
// trigger: {
// type: RewardTriggerEvent.SignUp,
// product_ids: [],
// exclude_trial: false,
// },
when: RewardTriggerEvent.CustomerCreation,
product_ids: [],
exclude_trial: false,
internal_reward_id: "",
max_redemptions: 0,
received_by: RewardReceivedBy.Referrer,
};
function CreateRewardProgramModal() {
const { refetch } = useRewardsQuery();
const axiosInstance = useAxiosInstance();
const { refetch } = useRewardsQuery();
const axiosInstance = useAxiosInstance();
const [isLoading, setIsLoading] = useState(false);
const [open, setOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [open, setOpen] = useState(false);
const [rewardProgram, setRewardProgram] = useState(defaultRewardProgram);
const [rewardProgram, setRewardProgram] = useState(defaultRewardProgram);
useEffect(() => {
if (open) {
setRewardProgram(defaultRewardProgram);
}
}, [open]);
useEffect(() => {
if (open) {
setRewardProgram(defaultRewardProgram);
}
}, [open]);
const handleCreate = async () => {
setIsLoading(true);
try {
await axiosInstance.post("/v1/reward_programs", rewardProgram);
const handleCreate = () => {
setIsLoading(true);
(async () => {
try {
await axiosInstance.post("/v1/reward_programs", rewardProgram);
await refetch();
setOpen(false);
} catch (error) {
toast.error(getBackendErr(error, "Failed to create referral program"));
}
setIsLoading(false);
};
await refetch();
setOpen(false);
} catch (error) {
toast.error(getBackendErr(error, "Failed to create referral program"));
} finally {
setIsLoading(false);
}
})();
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="add">Referral Program</Button>
</DialogTrigger>
<DialogContent className="w-[500px]">
<DialogHeader>
<DialogTitle>Create Referral Program</DialogTitle>
</DialogHeader>
<RewardProgramConfig
rewardProgram={rewardProgram as any}
setRewardProgram={setRewardProgram}
/>
<DialogFooter>
<Button
onClick={handleCreate}
isLoading={isLoading}
variant="gradientPrimary"
>
Create
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="add">Referral Program</Button>
</DialogTrigger>
<DialogContent className="w-[500px]">
<DialogHeader>
<DialogTitle>Create Referral Program</DialogTitle>
</DialogHeader>
<RewardProgramConfig
rewardProgram={rewardProgram as any}
setRewardProgram={setRewardProgram}
/>
<DialogFooter>
<Button
onClick={handleCreate}
isLoading={isLoading}
variant="gradientPrimary"
>
Create
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export default CreateRewardProgramModal;

View File

@@ -1,258 +1,257 @@
import {
type Reward,
type RewardProgram,
RewardReceivedBy,
RewardTriggerEvent,
} from "@autumn/shared";
import { Check, ChevronsUpDown, X } from "lucide-react";
import { useState } from "react";
import FieldLabel from "@/components/general/modal-components/FieldLabel";
import { Button } from "@/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import { Input } from "@/components/ui/input";
import {
Select,
SelectTrigger,
SelectContent,
SelectItem,
SelectValue,
} from "@/components/ui/select";
import {
Reward,
RewardProgram,
RewardTriggerEvent,
RewardReceivedBy,
} from "@autumn/shared";
import { useProductsContext } from "../../ProductsContext";
import { keyToTitle } from "@/utils/formatUtils/formatTextUtils";
import { useState } from "react";
import {
Popover,
PopoverContent,
PopoverTrigger,
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Button } from "@/components/ui/button";
import { Check, ChevronsUpDown, X } from "lucide-react";
import { useRewardsQuery } from "@/hooks/queries/useRewardsQuery";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useProductsQuery } from "@/hooks/queries/useProductsQuery";
import { useRewardsQuery } from "@/hooks/queries/useRewardsQuery";
import { keyToTitle } from "@/utils/formatUtils/formatTextUtils";
export const RewardProgramConfig = ({
rewardProgram,
setRewardProgram,
rewardProgram,
setRewardProgram,
}: {
rewardProgram: RewardProgram;
setRewardProgram: (rewardProgram: RewardProgram) => void;
rewardProgram: RewardProgram;
setRewardProgram: (rewardProgram: RewardProgram) => void;
}) => {
const { rewards } = useRewardsQuery();
const { rewards } = useRewardsQuery();
return (
<div className="flex flex-col gap-4">
<div className="flex items-center gap-2">
<div className="w-6/12">
<FieldLabel>Program ID</FieldLabel>
<Input
value={rewardProgram.id || ""}
onChange={(e) =>
setRewardProgram({ ...rewardProgram, id: e.target.value })
}
/>
</div>
<div className="w-6/12">
<FieldLabel>Reward</FieldLabel>
<Select
value={rewardProgram.internal_reward_id}
onValueChange={(value) =>
setRewardProgram({ ...rewardProgram, internal_reward_id: value })
}
>
<SelectTrigger>
<SelectValue placeholder="Select a reward" />
</SelectTrigger>
<SelectContent>
{rewards.map((reward: Reward) => (
<SelectItem key={reward.name} value={reward.internal_id}>
{reward.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="flex items-center gap-2">
<div className="w-6/12">
<FieldLabel>Redeem On</FieldLabel>
<Select
defaultValue={RewardTriggerEvent.CustomerCreation}
value={rewardProgram.when}
onValueChange={(value) =>
setRewardProgram({
...rewardProgram,
when: value as RewardTriggerEvent,
})
}
>
<SelectTrigger>
<SelectValue placeholder="Select a redeem on" />
</SelectTrigger>
<SelectContent>
{Object.values(RewardTriggerEvent).map((event) => (
<SelectItem key={event} value={event}>
{keyToTitle(event)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="w-6/12">
<FieldLabel>Max Redemptions</FieldLabel>
<Input
type="number"
value={rewardProgram.max_redemptions}
onChange={(e) =>
setRewardProgram({
...rewardProgram,
max_redemptions: parseInt(e.target.value),
})
}
/>
</div>
</div>
<div className="flex items-center gap-2">
<div className="w-full">
<FieldLabel>Received by</FieldLabel>
<Select
value={rewardProgram.received_by}
onValueChange={(value) =>
setRewardProgram({
...rewardProgram,
received_by: value as RewardReceivedBy,
})
}
>
<SelectTrigger>
<SelectValue placeholder="Who should receive the reward" />
</SelectTrigger>
<SelectContent>
{Object.values(RewardReceivedBy).map((receivedBy) => (
<SelectItem key={receivedBy} value={receivedBy}>
{receivedBy === RewardReceivedBy.All
? "Referrer & Redeemer"
: keyToTitle(receivedBy)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="flex items-center gap-2">
{rewardProgram.when === RewardTriggerEvent.Checkout && (
<div className="w-full">
<FieldLabel>Products</FieldLabel>
<ProductSelector
rewardProgram={rewardProgram}
setRewardProgram={setRewardProgram}
/>
</div>
)}
</div>
</div>
);
return (
<div className="flex flex-col gap-4">
<div className="flex items-center gap-2">
<div className="w-6/12">
<FieldLabel>Program ID</FieldLabel>
<Input
value={rewardProgram.id || ""}
onChange={(e) =>
setRewardProgram({ ...rewardProgram, id: e.target.value })
}
/>
</div>
<div className="w-6/12">
<FieldLabel>Reward</FieldLabel>
<Select
value={rewardProgram.internal_reward_id}
onValueChange={(value) =>
setRewardProgram({ ...rewardProgram, internal_reward_id: value })
}
>
<SelectTrigger>
<SelectValue placeholder="Select a reward" />
</SelectTrigger>
<SelectContent>
{rewards.map((reward: Reward) => (
<SelectItem key={reward.name} value={reward.internal_id}>
{reward.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="flex items-center gap-2">
<div className="w-6/12">
<FieldLabel>Redeem On</FieldLabel>
<Select
defaultValue={RewardTriggerEvent.CustomerCreation}
value={rewardProgram.when}
onValueChange={(value) =>
setRewardProgram({
...rewardProgram,
when: value as RewardTriggerEvent,
})
}
>
<SelectTrigger>
<SelectValue placeholder="Select a redeem on" />
</SelectTrigger>
<SelectContent>
{Object.values(RewardTriggerEvent).map((event) => (
<SelectItem key={event} value={event}>
{keyToTitle(event, { exclusionMap: { [RewardTriggerEvent.CustomerCreation]: "Customer Redemption" } })}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="w-6/12">
<FieldLabel>Max Redemptions</FieldLabel>
<Input
type="number"
value={rewardProgram.max_redemptions}
onChange={(e) =>
setRewardProgram({
...rewardProgram,
max_redemptions: parseInt(e.target.value),
})
}
/>
</div>
</div>
<div className="flex items-center gap-2">
<div className="w-full">
<FieldLabel>Received by</FieldLabel>
<Select
value={rewardProgram.received_by}
onValueChange={(value) =>
setRewardProgram({
...rewardProgram,
received_by: value as RewardReceivedBy,
})
}
>
<SelectTrigger>
<SelectValue placeholder="Who should receive the reward" />
</SelectTrigger>
<SelectContent>
{Object.values(RewardReceivedBy).map((receivedBy) => (
<SelectItem key={receivedBy} value={receivedBy}>
{receivedBy === RewardReceivedBy.All
? "Referrer & Redeemer"
: keyToTitle(receivedBy)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="flex items-center gap-2">
{rewardProgram.when === RewardTriggerEvent.Checkout && (
<div className="w-full">
<FieldLabel>Products</FieldLabel>
<ProductSelector
rewardProgram={rewardProgram}
setRewardProgram={setRewardProgram}
/>
</div>
)}
</div>
</div>
);
};
const ProductSelector = ({
rewardProgram,
setRewardProgram,
rewardProgram,
setRewardProgram,
}: {
rewardProgram: RewardProgram;
setRewardProgram: (rewardProgram: RewardProgram) => void;
rewardProgram: RewardProgram;
setRewardProgram: (rewardProgram: RewardProgram) => void;
}) => {
const { products } = useProductsQuery();
const [open, setOpen] = useState(false);
const { products } = useProductsQuery();
const [open, setOpen] = useState(false);
// Handle selection/deselection of a product
const handleProductToggle = (productId: string) => {
let newProductIds = [...(rewardProgram.product_ids || [])];
if (newProductIds.includes(productId)) {
newProductIds = newProductIds.filter((id) => id !== productId);
} else {
newProductIds = [...newProductIds, productId];
}
setRewardProgram({
...rewardProgram,
product_ids: newProductIds,
});
};
// Handle selection/deselection of a product
const handleProductToggle = (productId: string) => {
let newProductIds = [...(rewardProgram.product_ids || [])];
if (newProductIds.includes(productId)) {
newProductIds = newProductIds.filter((id) => id !== productId);
} else {
newProductIds = [...newProductIds, productId];
}
setRewardProgram({
...rewardProgram,
product_ids: newProductIds,
});
};
if (!products || products.length === 0) {
return <p className="text-sm text-t3">No products available</p>;
}
if (!products || products.length === 0) {
return <p className="text-sm text-t3">No products available</p>;
}
const getProductText = (productId: string) => {
const product = products.find((p: any) => p.id === productId);
return product?.name || "Unknown Product";
};
const getProductText = (productId: string) => {
const product = products.find((p: any) => p.id === productId);
return product?.name || "Unknown Product";
};
return (
<Popover modal open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="w-full min-h-9 flex flex-wrap h-fit py-2 justify-start items-center gap-2 relative hover:bg-zinc-50 data-[state=open]:border-focus data-[state=open]:shadow-focus"
>
{rewardProgram.product_ids?.length === 0 ? (
"Select Products"
) : (
<>
{rewardProgram.product_ids?.map((productId: string) => (
<div
key={productId}
className="py-0 px-3 text-xs text-t3 border-zinc-300 bg-zinc-100 rounded-full w-fit flex items-center gap-2 h-fit"
>
<p className="text-t2">{getProductText(productId)}</p>
<Button
variant="ghost"
size="icon"
onClick={(e) => {
e.stopPropagation();
handleProductToggle(productId);
}}
className="bg-transparent hover:bg-transparent p-0 w-5 h-5"
>
<X size={12} className="text-t3" />
</Button>
</div>
))}
</>
)}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50 absolute right-2" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[400px] p-0" align="start">
<Command>
<CommandInput placeholder="Search products..." className="h-9" />
<CommandList className="max-h-[300px] overflow-y-auto">
<ScrollArea>
<CommandEmpty>No products found.</CommandEmpty>
<CommandGroup>
{products.map((product: any) => (
<CommandItem
key={product.id}
value={product.id}
onSelect={() => handleProductToggle(product.id)}
className="cursor-pointer"
>
<div className="flex items-center">{product.name}</div>
{rewardProgram.product_ids?.includes(product.id) && (
<Check size={12} className="text-t3" />
)}
</CommandItem>
))}
</CommandGroup>
</ScrollArea>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
return (
<Popover modal open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="w-full min-h-9 flex flex-wrap h-fit py-2 justify-start items-center gap-2 relative hover:bg-zinc-50 data-[state=open]:border-focus data-[state=open]:shadow-focus"
>
{rewardProgram.product_ids?.length === 0 ? (
"Select Products"
) : (
<>
{rewardProgram.product_ids?.map((productId: string) => (
<div
key={productId}
className="py-0 px-3 text-xs text-t3 border-zinc-300 bg-zinc-100 rounded-full w-fit flex items-center gap-2 h-fit"
>
<p className="text-t2">{getProductText(productId)}</p>
<Button
variant="ghost"
size="icon"
onClick={(e) => {
e.stopPropagation();
handleProductToggle(productId);
}}
className="bg-transparent hover:bg-transparent p-0 w-5 h-5"
>
<X size={12} className="text-t3" />
</Button>
</div>
))}
</>
)}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50 absolute right-2" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[400px] p-0" align="start">
<Command>
<CommandInput placeholder="Search products..." className="h-9" />
<CommandList className="max-h-[300px] overflow-y-auto">
<ScrollArea>
<CommandEmpty>No products found.</CommandEmpty>
<CommandGroup>
{products.map((product: any) => (
<CommandItem
key={product.id}
value={product.id}
onSelect={() => handleProductToggle(product.id)}
className="cursor-pointer"
>
<div className="flex items-center">{product.name}</div>
{rewardProgram.product_ids?.includes(product.id) && (
<Check size={12} className="text-t3" />
)}
</CommandItem>
))}
</CommandGroup>
</ScrollArea>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
};

View File

@@ -1,29 +1,38 @@
import {
CreateReward,
RewardType,
CouponDurationType,
DiscountConfig,
type CreateReward,
type DiscountConfig,
type FreeProductConfig,
RewardType,
} from "@autumn/shared";
export const defaultDiscountConfig: DiscountConfig = {
discount_value: 0,
duration_type: CouponDurationType.Months,
duration_value: 0,
should_rollover: true,
apply_to_all: true,
price_ids: [],
discount_value: 0,
duration_type: CouponDurationType.Months,
duration_value: 0,
should_rollover: true,
apply_to_all: true,
price_ids: [],
};
export const defaultFreeProductConfig: FreeProductConfig = {
duration_type: CouponDurationType.Months,
duration_value: 0,
};
export const defaultReward: CreateReward = {
name: "",
id: "",
promo_codes: [{ code: "" }],
name: "",
id: "",
promo_codes: [{ code: "" }],
type: RewardType.PercentageDiscount,
type: RewardType.PercentageDiscount,
// For free product coupons
free_product_id: null,
// For free product coupons
free_product_id: null,
// For discount type coupons
discount_config: defaultDiscountConfig,
// For discount type coupons
discount_config: defaultDiscountConfig,
// For free product type coupons
free_product_config: defaultFreeProductConfig,
};