Merge branch 'staging' into Fixes/Update-logic-reward-program
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -107,4 +107,4 @@ migration.sh
|
||||
stat.sh
|
||||
|
||||
CLAUDE.md
|
||||
interview
|
||||
interview
|
||||
|
||||
@@ -15,7 +15,10 @@
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"recommended": true
|
||||
"recommended": true,
|
||||
"complexity": {
|
||||
"noStaticOnlyClass": "off"
|
||||
}
|
||||
}
|
||||
},
|
||||
"javascript": {
|
||||
|
||||
15047
pnpm-lock.yaml
generated
Normal file
15047
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -26,6 +26,9 @@
|
||||
"noExportsInTest": "off",
|
||||
"noExplicitAny": "off",
|
||||
"noImplicitAnyLet": "off"
|
||||
},
|
||||
"complexity": {
|
||||
"noStaticOnlyClass": "off"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -7,16 +7,13 @@ 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/referrals/paid/*.ts' \
|
||||
'tests/advanced/rollovers/*.ts' \
|
||||
'tests/advanced/customInterval/*.ts'
|
||||
|
||||
|
||||
862
server/src/external/autumn/autumnCli.ts
vendored
862
server/src/external/autumn/autumnCli.ts
vendored
@@ -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`, {});
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -13,6 +13,7 @@ import { SupabaseClient } from "@supabase/supabase-js";
|
||||
import Stripe from "stripe";
|
||||
import { billingIntervalToStripe } from "../stripePriceUtils.js";
|
||||
import {
|
||||
formatPrice,
|
||||
getBillingType,
|
||||
getPriceEntitlement,
|
||||
} from "@/internal/products/prices/priceUtils.js";
|
||||
|
||||
@@ -34,8 +34,6 @@ export const checkCurStripePrice = async ({
|
||||
stripeCli: Stripe;
|
||||
currency: string;
|
||||
}) => {
|
||||
let priceValid = false;
|
||||
|
||||
let config = price.config! as UsagePriceConfig;
|
||||
|
||||
let stripePrice: Stripe.Price | null = null;
|
||||
@@ -48,7 +46,9 @@ export const checkCurStripePrice = async ({
|
||||
});
|
||||
|
||||
if (!stripePrice.active) {
|
||||
stripePrice = null;
|
||||
stripePrice = await stripeCli.prices.update(config.stripe_price_id!, {
|
||||
active: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
|
||||
130
server/src/external/stripe/stripeCouponUtils/extendCouponDuration.ts
vendored
Normal file
130
server/src/external/stripe/stripeCouponUtils/extendCouponDuration.ts
vendored
Normal 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 };
|
||||
// }
|
||||
// };
|
||||
@@ -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
|
||||
|
||||
520
server/src/external/stripe/stripeCusUtils.ts
vendored
520
server/src/external/stripe/stripeCusUtils.ts
vendored
@@ -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`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
36
server/src/external/stripe/stripeWebhooks.ts
vendored
36
server/src/external/stripe/stripeWebhooks.ts
vendored
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,50 +1,52 @@
|
||||
import { ClickHouseClient } from "@clickhouse/client";
|
||||
import { ErrCode, FullCustomer } from "@autumn/shared";
|
||||
import { ExtendedRequest } from "@/utils/models/Request.js";
|
||||
import RecaseError from "@/utils/errorUtils.js";
|
||||
/** biome-ignore-all lint/complexity/noStaticOnlyClass: wrap it up buddy */
|
||||
|
||||
import { ErrCode, type FullCustomer } from "@autumn/shared";
|
||||
import type { ClickHouseClient } from "@clickhouse/client";
|
||||
import { StatusCodes } from "http-status-codes";
|
||||
import RecaseError from "@/utils/errorUtils.js";
|
||||
import type { ExtendedRequest } from "@/utils/models/Request.js";
|
||||
import {
|
||||
generateEventCountExpressions,
|
||||
getBillingCycleStartDate,
|
||||
} from "./analyticsUtils.js";
|
||||
|
||||
export class AnalyticsService {
|
||||
static clickhouseAvailable =
|
||||
process.env.CLICKHOUSE_URL &&
|
||||
process.env.CLICKHOUSE_USERNAME &&
|
||||
process.env.CLICKHOUSE_PASSWORD;
|
||||
static clickhouseAvailable =
|
||||
process.env.CLICKHOUSE_URL &&
|
||||
process.env.CLICKHOUSE_USERNAME &&
|
||||
process.env.CLICKHOUSE_PASSWORD;
|
||||
|
||||
static handleEarlyExit = () => {
|
||||
if (!AnalyticsService.clickhouseAvailable) {
|
||||
throw new RecaseError({
|
||||
message: "ClickHouse is disabled, cannot fetch events",
|
||||
code: ErrCode.ClickHouseDisabled,
|
||||
statusCode: StatusCodes.SERVICE_UNAVAILABLE,
|
||||
});
|
||||
}
|
||||
};
|
||||
static handleEarlyExit = () => {
|
||||
if (!AnalyticsService.clickhouseAvailable) {
|
||||
throw new RecaseError({
|
||||
message: "ClickHouse is disabled, cannot fetch events",
|
||||
code: ErrCode.ClickHouseDisabled,
|
||||
statusCode: StatusCodes.SERVICE_UNAVAILABLE,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
static formatJsDateToClickHouseDateTime(date: Date) {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
const hours = String(date.getHours()).padStart(2, "0");
|
||||
const minutes = String(date.getMinutes() - 1).padStart(2, "0");
|
||||
const seconds = String(date.getSeconds() - 1).padStart(2, "0");
|
||||
static formatJsDateToClickHouseDateTime(date: Date) {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
const hours = String(date.getHours()).padStart(2, "0");
|
||||
const minutes = String(date.getMinutes() - 1).padStart(2, "0");
|
||||
const seconds = String(date.getSeconds() - 1).padStart(2, "0");
|
||||
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
|
||||
}
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
|
||||
}
|
||||
|
||||
static async getTopEventNames({
|
||||
req,
|
||||
limit = 3,
|
||||
}: {
|
||||
req: ExtendedRequest;
|
||||
limit?: number;
|
||||
}) {
|
||||
const { clickhouseClient, org, env } = req;
|
||||
static async getTopEventNames({
|
||||
req,
|
||||
limit = 3,
|
||||
}: {
|
||||
req: ExtendedRequest;
|
||||
limit?: number;
|
||||
}) {
|
||||
const { clickhouseClient, org, env } = req;
|
||||
|
||||
const query = `
|
||||
const query = `
|
||||
select count(*) as count, event_name
|
||||
from org_events_view(org_id={org_id:String}, org_slug='', env={env:String})
|
||||
where timestamp >= NOW() - INTERVAL '1 month'
|
||||
@@ -52,27 +54,27 @@ export class AnalyticsService {
|
||||
order by count(*) desc
|
||||
limit {limit:UInt32}
|
||||
`;
|
||||
const result = await clickhouseClient.query({
|
||||
query,
|
||||
query_params: {
|
||||
org_id: org?.id,
|
||||
env: env,
|
||||
limit,
|
||||
},
|
||||
});
|
||||
const result = await clickhouseClient.query({
|
||||
query,
|
||||
query_params: {
|
||||
org_id: org?.id,
|
||||
env: env,
|
||||
limit,
|
||||
},
|
||||
});
|
||||
|
||||
const resultJson = await result.json();
|
||||
const resultJson = await result.json();
|
||||
|
||||
return {
|
||||
eventNames: resultJson.data.map((row: any) => row.event_name),
|
||||
result: resultJson,
|
||||
};
|
||||
}
|
||||
return {
|
||||
eventNames: resultJson.data.map((row: any) => row.event_name),
|
||||
result: resultJson,
|
||||
};
|
||||
}
|
||||
|
||||
static async getTopUser({ req }: { req: ExtendedRequest }) {
|
||||
const { clickhouseClient, org, env, db } = req;
|
||||
static async getTopUser({ req }: { req: ExtendedRequest }) {
|
||||
const { clickhouseClient, org, env, db } = req;
|
||||
|
||||
const query = `
|
||||
const query = `
|
||||
SELECT
|
||||
c.name
|
||||
FROM
|
||||
@@ -113,116 +115,118 @@ WHERE
|
||||
)
|
||||
`;
|
||||
|
||||
const result = await clickhouseClient.query({
|
||||
query,
|
||||
query_params: {
|
||||
org_id: org?.id,
|
||||
env: env,
|
||||
},
|
||||
});
|
||||
const result = await clickhouseClient.query({
|
||||
query,
|
||||
query_params: {
|
||||
org_id: org?.id,
|
||||
env: env,
|
||||
},
|
||||
});
|
||||
|
||||
const resultJson = await result.json();
|
||||
const resultJson = await result.json();
|
||||
|
||||
return (resultJson.data as { name: string; count: number }[])[0];
|
||||
}
|
||||
return (resultJson.data as { name: string; count: number }[])[0];
|
||||
}
|
||||
|
||||
static async getTotalEvents({
|
||||
req,
|
||||
eventName,
|
||||
}: {
|
||||
req: ExtendedRequest;
|
||||
eventName?: string;
|
||||
}) {
|
||||
const { clickhouseClient, org, env, db } = req;
|
||||
static async getTotalEvents({
|
||||
req,
|
||||
eventName,
|
||||
}: {
|
||||
req: ExtendedRequest;
|
||||
eventName?: string;
|
||||
}) {
|
||||
const { clickhouseClient, org, env, db } = req;
|
||||
|
||||
const query = `
|
||||
SELECT org_id, env, COUNT(*) AS total_events
|
||||
FROM events
|
||||
WHERE org_id = {org_id: String}
|
||||
AND env = {env: String}
|
||||
${eventName ? `AND event_name = {eventName: String}` : ""}
|
||||
GROUP BY org_id, env
|
||||
LIMIT 1;
|
||||
const query = `
|
||||
SELECT SUM(
|
||||
CASE
|
||||
WHEN JSONHas(properties, 'value') THEN toInt64(JSONExtractFloat(properties, 'value'))
|
||||
WHEN value IS NOT NULL THEN toInt64(value)
|
||||
ELSE 1
|
||||
END
|
||||
) AS total_events
|
||||
FROM org_events_view(org_id={org_id:String}, org_slug='', env={env:String})
|
||||
WHERE event_name = {eventName:String}
|
||||
`;
|
||||
|
||||
const result = await clickhouseClient.query({
|
||||
query,
|
||||
query_params: {
|
||||
org_id: org?.id,
|
||||
env: env,
|
||||
eventName: eventName ?? undefined,
|
||||
},
|
||||
});
|
||||
const result = await clickhouseClient.query({
|
||||
query,
|
||||
query_params: {
|
||||
org_id: org?.id,
|
||||
env: env,
|
||||
eventName: eventName ?? undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const resultJson = await result.json();
|
||||
const resultJson = await result.json();
|
||||
|
||||
return (resultJson.data as { total_events: number }[])[0].total_events;
|
||||
}
|
||||
return (resultJson.data as { total_events: number }[])[0].total_events;
|
||||
}
|
||||
|
||||
static async getTotalCustomers({ req }: { req: ExtendedRequest }) {
|
||||
const { clickhouseClient, org, env, db } = req;
|
||||
const query = `SELECT COUNT(DISTINCT id) AS total_customers
|
||||
static async getTotalCustomers({ req }: { req: ExtendedRequest }) {
|
||||
const { clickhouseClient, org, env, db } = req;
|
||||
const query = `SELECT COUNT(DISTINCT id) AS total_customers
|
||||
FROM customers
|
||||
WHERE org_id = {org_id:String}
|
||||
AND env = {env:String};`;
|
||||
|
||||
const result = await clickhouseClient.query({
|
||||
query,
|
||||
query_params: {
|
||||
org_id: org?.id,
|
||||
env: env,
|
||||
},
|
||||
});
|
||||
const result = await clickhouseClient.query({
|
||||
query,
|
||||
query_params: {
|
||||
org_id: org?.id,
|
||||
env: env,
|
||||
},
|
||||
});
|
||||
|
||||
const resultJson = await result.json();
|
||||
const resultJson = await result.json();
|
||||
|
||||
return (resultJson.data as { total_customers: number }[])[0]
|
||||
.total_customers;
|
||||
}
|
||||
return (resultJson.data as { total_customers: number }[])[0]
|
||||
.total_customers;
|
||||
}
|
||||
|
||||
static async getTimeseriesEvents({
|
||||
req,
|
||||
params,
|
||||
customer,
|
||||
aggregateAll = false,
|
||||
}: {
|
||||
req: ExtendedRequest;
|
||||
params: {
|
||||
event_names: string[];
|
||||
interval: "24h" | "7d" | "30d" | "90d" | "1bc" | "3bc";
|
||||
customer_id?: string;
|
||||
no_count?: boolean;
|
||||
};
|
||||
customer?: FullCustomer;
|
||||
aggregateAll?: boolean;
|
||||
}) {
|
||||
const { clickhouseClient, org, env, db } = req;
|
||||
static async getTimeseriesEvents({
|
||||
req,
|
||||
params,
|
||||
customer,
|
||||
aggregateAll = false,
|
||||
}: {
|
||||
req: ExtendedRequest;
|
||||
params: {
|
||||
event_names: string[];
|
||||
interval: "24h" | "7d" | "30d" | "90d" | "1bc" | "3bc";
|
||||
customer_id?: string;
|
||||
no_count?: boolean;
|
||||
};
|
||||
customer?: FullCustomer;
|
||||
aggregateAll?: boolean;
|
||||
}) {
|
||||
const { clickhouseClient, org, env, db } = req;
|
||||
|
||||
const intervalType: "24h" | "7d" | "30d" | "90d" | "1bc" | "3bc" =
|
||||
params.interval || "24h";
|
||||
const intervalType: "24h" | "7d" | "30d" | "90d" | "1bc" | "3bc" =
|
||||
params.interval || "24h";
|
||||
|
||||
const isBillingCycle = intervalType === "1bc" || intervalType === "3bc";
|
||||
AnalyticsService.handleEarlyExit();
|
||||
const isBillingCycle = intervalType === "1bc" || intervalType === "3bc";
|
||||
AnalyticsService.handleEarlyExit();
|
||||
|
||||
// Skip billing cycle calculation if aggregating all customers
|
||||
let getBCResults =
|
||||
isBillingCycle && !aggregateAll && customer
|
||||
? ((await getBillingCycleStartDate(
|
||||
env,
|
||||
org?.id,
|
||||
customer,
|
||||
db,
|
||||
intervalType as "1bc" | "3bc"
|
||||
)) as { startDate: string; endDate: string; gap: number } | null)
|
||||
: null;
|
||||
// Skip billing cycle calculation if aggregating all customers
|
||||
const getBCResults =
|
||||
isBillingCycle && !aggregateAll && customer
|
||||
? ((await getBillingCycleStartDate(
|
||||
env,
|
||||
org?.id,
|
||||
customer,
|
||||
db,
|
||||
intervalType as "1bc" | "3bc",
|
||||
)) as { startDate: string; endDate: string; gap: number } | null)
|
||||
: null;
|
||||
|
||||
const countExpressions = generateEventCountExpressions(
|
||||
params.event_names,
|
||||
params.no_count
|
||||
);
|
||||
const countExpressions = generateEventCountExpressions(
|
||||
params.event_names,
|
||||
params.no_count,
|
||||
);
|
||||
|
||||
if (AnalyticsService.clickhouseAvailable) {
|
||||
const query = `
|
||||
if (AnalyticsService.clickhouseAvailable) {
|
||||
const query = `
|
||||
with customer_events as (
|
||||
select *
|
||||
from org_events_view(org_id={org_id:String}, org_slug='', env={env:String})
|
||||
@@ -238,7 +242,7 @@ group by dr.period
|
||||
order by dr.period;
|
||||
`;
|
||||
|
||||
const queryBillingCycle = `
|
||||
const queryBillingCycle = `
|
||||
with customer_events as (
|
||||
select *
|
||||
from org_events_view(org_id={org_id:String}, org_slug='', env={env:String})
|
||||
@@ -254,118 +258,118 @@ group by dr.period
|
||||
order by dr.period;
|
||||
`;
|
||||
|
||||
const queryParams = {
|
||||
org_id: org?.id,
|
||||
env: env,
|
||||
customer_id: params.customer_id,
|
||||
days:
|
||||
intervalType === "24h"
|
||||
? 1
|
||||
: intervalType === "7d"
|
||||
? 7
|
||||
: intervalType === "30d"
|
||||
? 30
|
||||
: intervalType === "90d"
|
||||
? 90
|
||||
: intervalType === "1bc"
|
||||
? (getBCResults?.gap ?? 0) + 1
|
||||
: intervalType === "3bc"
|
||||
? (getBCResults?.gap ?? 0)
|
||||
: 0,
|
||||
bin_size: intervalType === "24h" ? "hour" : "day",
|
||||
end_date: isBillingCycle ? getBCResults?.endDate : undefined,
|
||||
};
|
||||
const queryParams = {
|
||||
org_id: org?.id,
|
||||
env: env,
|
||||
customer_id: params.customer_id,
|
||||
days:
|
||||
intervalType === "24h"
|
||||
? 1
|
||||
: intervalType === "7d"
|
||||
? 7
|
||||
: intervalType === "30d"
|
||||
? 30
|
||||
: intervalType === "90d"
|
||||
? 90
|
||||
: intervalType === "1bc"
|
||||
? (getBCResults?.gap ?? 0) + 1
|
||||
: intervalType === "3bc"
|
||||
? (getBCResults?.gap ?? 0)
|
||||
: 0,
|
||||
bin_size: intervalType === "24h" ? "hour" : "day",
|
||||
end_date: isBillingCycle ? getBCResults?.endDate : undefined,
|
||||
};
|
||||
|
||||
// Use regular query for aggregateAll or when no billing cycle data is available
|
||||
const queryToUse =
|
||||
isBillingCycle && !aggregateAll && getBCResults?.startDate
|
||||
? queryBillingCycle
|
||||
: query;
|
||||
// Use regular query for aggregateAll or when no billing cycle data is available
|
||||
const queryToUse =
|
||||
isBillingCycle && !aggregateAll && getBCResults?.startDate
|
||||
? queryBillingCycle
|
||||
: query;
|
||||
|
||||
const result = await (clickhouseClient as ClickHouseClient).query({
|
||||
query: queryToUse,
|
||||
query_params: queryParams,
|
||||
format: "JSON",
|
||||
clickhouse_settings: {
|
||||
output_format_json_quote_decimals: 0,
|
||||
output_format_json_quote_64bit_integers: 1,
|
||||
output_format_json_quote_64bit_floats: 1,
|
||||
},
|
||||
});
|
||||
const result = await (clickhouseClient as ClickHouseClient).query({
|
||||
query: queryToUse,
|
||||
query_params: queryParams,
|
||||
format: "JSON",
|
||||
clickhouse_settings: {
|
||||
output_format_json_quote_decimals: 0,
|
||||
output_format_json_quote_64bit_integers: 1,
|
||||
output_format_json_quote_64bit_floats: 1,
|
||||
},
|
||||
});
|
||||
|
||||
let resultJson = await result.json();
|
||||
const resultJson = await result.json();
|
||||
|
||||
resultJson.data.forEach((row: any) => {
|
||||
Object.keys(row).forEach((key: string) => {
|
||||
if (key !== "period") {
|
||||
row[key] = parseInt(row[key]);
|
||||
}
|
||||
});
|
||||
});
|
||||
resultJson.data.forEach((row: any) => {
|
||||
Object.keys(row).forEach((key: string) => {
|
||||
if (key !== "period") {
|
||||
row[key] = parseInt(row[key]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return resultJson;
|
||||
}
|
||||
}
|
||||
return resultJson;
|
||||
}
|
||||
}
|
||||
|
||||
static async getRawEvents({
|
||||
req,
|
||||
params,
|
||||
customer,
|
||||
aggregateAll = false,
|
||||
}: {
|
||||
req: ExtendedRequest;
|
||||
params: any;
|
||||
customer?: FullCustomer;
|
||||
aggregateAll?: boolean;
|
||||
}) {
|
||||
const { clickhouseClient, org, db, env } = req;
|
||||
static async getRawEvents({
|
||||
req,
|
||||
params,
|
||||
customer,
|
||||
aggregateAll = false,
|
||||
}: {
|
||||
req: ExtendedRequest;
|
||||
params: any;
|
||||
customer?: FullCustomer;
|
||||
aggregateAll?: boolean;
|
||||
}) {
|
||||
const { clickhouseClient, org, db, env } = req;
|
||||
|
||||
AnalyticsService.handleEarlyExit();
|
||||
AnalyticsService.handleEarlyExit();
|
||||
|
||||
let startDate = new Date();
|
||||
const intervalType = params.interval || "day";
|
||||
const isBillingCycle = intervalType === "1bc" || intervalType === "3bc";
|
||||
const startDate = new Date();
|
||||
const intervalType = params.interval || "day";
|
||||
const isBillingCycle = intervalType === "1bc" || intervalType === "3bc";
|
||||
|
||||
// Skip billing cycle calculation if aggregating all customers
|
||||
let getBCResults =
|
||||
isBillingCycle && !aggregateAll && customer
|
||||
? ((await getBillingCycleStartDate(
|
||||
env,
|
||||
org?.id,
|
||||
customer,
|
||||
db,
|
||||
intervalType as "1bc" | "3bc"
|
||||
)) as { startDate: string; endDate: string; gap: number } | null)
|
||||
: null;
|
||||
// Skip billing cycle calculation if aggregating all customers
|
||||
const getBCResults =
|
||||
isBillingCycle && !aggregateAll && customer
|
||||
? ((await getBillingCycleStartDate(
|
||||
env,
|
||||
org?.id,
|
||||
customer,
|
||||
db,
|
||||
intervalType as "1bc" | "3bc",
|
||||
)) as { startDate: string; endDate: string; gap: number } | null)
|
||||
: null;
|
||||
|
||||
switch (intervalType) {
|
||||
case "24h":
|
||||
startDate.setHours(startDate.getHours() - 24);
|
||||
break;
|
||||
case "7d":
|
||||
startDate.setDate(startDate.getDate() - 7);
|
||||
break;
|
||||
case "30d":
|
||||
startDate.setDate(startDate.getDate() - 30);
|
||||
break;
|
||||
case "90d":
|
||||
startDate.setDate(startDate.getDate() - 90);
|
||||
break;
|
||||
default:
|
||||
startDate.setDate(startDate.getDate() - 24);
|
||||
break;
|
||||
}
|
||||
switch (intervalType) {
|
||||
case "24h":
|
||||
startDate.setHours(startDate.getHours() - 24);
|
||||
break;
|
||||
case "7d":
|
||||
startDate.setDate(startDate.getDate() - 7);
|
||||
break;
|
||||
case "30d":
|
||||
startDate.setDate(startDate.getDate() - 30);
|
||||
break;
|
||||
case "90d":
|
||||
startDate.setDate(startDate.getDate() - 90);
|
||||
break;
|
||||
default:
|
||||
startDate.setDate(startDate.getDate() - 24);
|
||||
break;
|
||||
}
|
||||
|
||||
const finalStartDate =
|
||||
isBillingCycle && getBCResults?.startDate
|
||||
? getBCResults.startDate
|
||||
: AnalyticsService.formatJsDateToClickHouseDateTime(startDate);
|
||||
const finalEndDate =
|
||||
isBillingCycle && getBCResults?.endDate
|
||||
? getBCResults.endDate
|
||||
: AnalyticsService.formatJsDateToClickHouseDateTime(new Date());
|
||||
const finalStartDate =
|
||||
isBillingCycle && getBCResults?.startDate
|
||||
? getBCResults.startDate
|
||||
: AnalyticsService.formatJsDateToClickHouseDateTime(startDate);
|
||||
const finalEndDate =
|
||||
isBillingCycle && getBCResults?.endDate
|
||||
? getBCResults.endDate
|
||||
: AnalyticsService.formatJsDateToClickHouseDateTime(new Date());
|
||||
|
||||
const query = `
|
||||
const query = `
|
||||
SELECT *
|
||||
FROM org_events_view(org_id={organizationId:String}, org_slug='', env={env:String})
|
||||
WHERE timestamp >= toDateTime({startDate:String})
|
||||
@@ -375,49 +379,49 @@ order by dr.period;
|
||||
limit 10000
|
||||
`;
|
||||
|
||||
const filledQuery = query
|
||||
.replace("{organizationId:String}", org?.id ?? "")
|
||||
.replace("{customerId:String}", params.customer_id ?? "")
|
||||
.replace("{startDate:String}", finalStartDate)
|
||||
.replace("{endDate:String}", finalEndDate)
|
||||
.replace("{env:String}", env);
|
||||
const filledQuery = query
|
||||
.replace("{organizationId:String}", org?.id ?? "")
|
||||
.replace("{customerId:String}", params.customer_id ?? "")
|
||||
.replace("{startDate:String}", finalStartDate)
|
||||
.replace("{endDate:String}", finalEndDate)
|
||||
.replace("{env:String}", env);
|
||||
|
||||
// console.log("filledQuery", filledQuery);
|
||||
// console.log("filledQuery", filledQuery);
|
||||
|
||||
const result = await clickhouseClient.query({
|
||||
query: query,
|
||||
query_params: {
|
||||
organizationId: org?.id,
|
||||
customerId: params.customer_id,
|
||||
startDate: finalStartDate,
|
||||
endDate: finalEndDate,
|
||||
env: env,
|
||||
},
|
||||
});
|
||||
const result = await clickhouseClient.query({
|
||||
query: query,
|
||||
query_params: {
|
||||
organizationId: org?.id,
|
||||
customerId: params.customer_id,
|
||||
startDate: finalStartDate,
|
||||
endDate: finalEndDate,
|
||||
env: env,
|
||||
},
|
||||
});
|
||||
|
||||
// log the actual query... with params filled in...?
|
||||
// console.log("query", query);
|
||||
// log the actual query... with params filled in...?
|
||||
// console.log("query", query);
|
||||
|
||||
const resultJson = await result.json();
|
||||
const resultJson = await result.json();
|
||||
|
||||
return resultJson;
|
||||
}
|
||||
return resultJson;
|
||||
}
|
||||
|
||||
// private static async getSubscriptionsIfNeeded(
|
||||
// customer: FullCustomer,
|
||||
// customerHasSubscriptions: boolean,
|
||||
// db: DrizzleCli
|
||||
// ): Promise<Subscription[]> {
|
||||
// if (customerHasSubscriptions) {
|
||||
// return [];
|
||||
// }
|
||||
// private static async getSubscriptionsIfNeeded(
|
||||
// customer: FullCustomer,
|
||||
// customerHasSubscriptions: boolean,
|
||||
// db: DrizzleCli
|
||||
// ): Promise<Subscription[]> {
|
||||
// if (customerHasSubscriptions) {
|
||||
// return [];
|
||||
// }
|
||||
|
||||
// return await SubService.getInStripeIds({
|
||||
// db,
|
||||
// ids:
|
||||
// customer.customer_products?.flatMap(
|
||||
// (product: FullCusProduct) => product.subscription_ids ?? []
|
||||
// ) ?? [],
|
||||
// });
|
||||
// }
|
||||
// return await SubService.getInStripeIds({
|
||||
// db,
|
||||
// ids:
|
||||
// customer.customer_products?.flatMap(
|
||||
// (product: FullCusProduct) => product.subscription_ids ?? []
|
||||
// ) ?? [],
|
||||
// });
|
||||
// }
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,218 @@
|
||||
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(),
|
||||
};
|
||||
|
||||
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,
|
||||
// },
|
||||
// });
|
||||
@@ -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";
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
@@ -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";
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
@@ -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",
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
@@ -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 };
|
||||
@@ -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);
|
||||
|
||||
@@ -1,114 +1,24 @@
|
||||
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 express, { type Router } from "express";
|
||||
import {
|
||||
handleCreateRewardProgram,
|
||||
handleDeleteRewardProgram,
|
||||
} from "./handlers/rewardPrograms/index.js";
|
||||
import { routeHandler } from "@/utils/routerUtils.js";
|
||||
import RecaseError from "@/utils/errorUtils.js";
|
||||
import {
|
||||
CreateRewardProgram,
|
||||
ErrCode,
|
||||
nullish,
|
||||
RewardTriggerEvent,
|
||||
} from "@autumn/shared";
|
||||
import express, { Router } from "express";
|
||||
import { RewardProgramService } from "@/internal/rewards/RewardProgramService.js";
|
||||
import { constructRewardProgram } from "@/internal/rewards/rewardTriggerUtils.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,
|
||||
idOrInternalId: 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,
|
||||
idOrInternalId: id,
|
||||
orgId,
|
||||
env,
|
||||
});
|
||||
|
||||
return res.status(200).json(rewardProgram);
|
||||
},
|
||||
})
|
||||
);
|
||||
rewardProgramRouter.delete("/:id", handleDeleteRewardProgram);
|
||||
|
||||
rewardProgramRouter.put("/:id", (req, res) =>
|
||||
routeHandler({
|
||||
|
||||
@@ -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;
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
import RecaseError from "@/utils/errorUtils.js";
|
||||
import { ErrCode } from "@/errors/errCodes.js";
|
||||
import { StatusCodes } from "http-status-codes";
|
||||
import { and, eq, or, sql } from "drizzle-orm";
|
||||
import { and, eq, ilike, or, sql } from "drizzle-orm";
|
||||
import { DrizzleCli } from "@/db/initDrizzle.js";
|
||||
import { getFullCusQuery } from "./getFullCusQuery.js";
|
||||
import { trace } from "@opentelemetry/api";
|
||||
@@ -151,7 +151,7 @@ export class CusService {
|
||||
}) {
|
||||
const customer = await db.query.customers.findMany({
|
||||
where: and(
|
||||
eq(customers.email, email),
|
||||
ilike(customers.email, email),
|
||||
eq(customers.org_id, orgId),
|
||||
eq(customers.env, env)
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
} from "@/internal/customers/cusProducts/AttachParams.js";
|
||||
import {
|
||||
attachParamsToCurCusProduct,
|
||||
attachParamsToProduct,
|
||||
paramsToCurSub,
|
||||
paramsToCurSubSchedule,
|
||||
} from "../../attachUtils/convertAttachParams.js";
|
||||
@@ -16,6 +17,8 @@ import {
|
||||
AttachConfig,
|
||||
AttachScenario,
|
||||
CusProductStatus,
|
||||
cusProductToProduct,
|
||||
logCusProducts,
|
||||
ProrationBehavior,
|
||||
} from "@autumn/shared";
|
||||
import { ExtendedRequest } from "@/utils/models/Request.js";
|
||||
@@ -72,13 +75,11 @@ export const handleUpgradeFlow = async ({
|
||||
|
||||
const { subItems } = newItemSet;
|
||||
|
||||
// for (const item of subItems) {
|
||||
// const { autumnPrice, ...rest } = item;
|
||||
// console.log("ITEM:", rest);
|
||||
// }
|
||||
const products = attachParams.fromCancel
|
||||
? [cusProductToProduct({ cusProduct: attachParams.cusProduct! })]
|
||||
: attachParams.products;
|
||||
|
||||
// Delete scheduled products if needed
|
||||
for (const product of attachParams.products) {
|
||||
for (const product of products) {
|
||||
if (product.is_add_on) continue;
|
||||
|
||||
const { curScheduledProduct } = getExistingCusProducts({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
ErrCode,
|
||||
FullCusProduct,
|
||||
FullCustomer,
|
||||
APIVersion,
|
||||
ProrationBehavior,
|
||||
AttachBranch,
|
||||
Price,
|
||||
@@ -16,9 +15,9 @@ import { createStripeCli } from "@/external/stripe/utils.js";
|
||||
import RecaseError from "@/utils/errorUtils.js";
|
||||
import { StatusCodes } from "http-status-codes";
|
||||
import { isFreeProduct, isOneOff } from "@/internal/products/productUtils.js";
|
||||
import { cancelEndOfCycle } from "./cancelEndOfCycle.js";
|
||||
|
||||
import { cancelImmediately } from "./cancelImmediately.js";
|
||||
import { cancelScheduledProduct } from "./cancelScheduledProduct.js";
|
||||
|
||||
import { handleRenewProduct } from "../attach/attachFunctions/handleRenewProduct.js";
|
||||
import { getDefaultAttachConfig } from "../attach/attachUtils/getAttachConfig.js";
|
||||
import { handleScheduleFunction2 } from "../attach/attachFunctions/scheduleFlow/handleScheduleFlow2.js";
|
||||
|
||||
@@ -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[];
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -78,7 +78,6 @@ const handleIdIsNull = async ({
|
||||
}
|
||||
|
||||
// 2. Check if email already exists
|
||||
|
||||
let existingCustomers = await CusService.getByEmail({
|
||||
db,
|
||||
email: newCus.email,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
268
server/src/internal/migrations/runRewardMigrationTask.ts
Normal file
268
server/src/internal/migrations/runRewardMigrationTask.ts
Normal file
@@ -0,0 +1,268 @@
|
||||
import {
|
||||
type AppEnv,
|
||||
type FixedPriceConfig,
|
||||
type FullProduct,
|
||||
type Price,
|
||||
type UsagePriceConfig,
|
||||
DiscountConfig,
|
||||
PriceType,
|
||||
RewardType,
|
||||
getBillingType,
|
||||
isFixedPrice,
|
||||
isUsagePrice,
|
||||
} from "@autumn/shared";
|
||||
|
||||
import type { DrizzleCli } from "@/db/initDrizzle.js";
|
||||
import type { logger as loggerType } from "@/external/logtail/logtailUtils.js";
|
||||
import type { JobName } from "@/queue/JobName.js";
|
||||
import type { Payloads } from "@/queue/queueUtils.js";
|
||||
import { RewardService } from "../rewards/RewardService.js";
|
||||
import { tiersAreSame } from "../products/prices/priceInitUtils.js";
|
||||
import { createStripeCoupon } from "@/external/stripe/stripeCouponUtils/stripeCouponUtils.js";
|
||||
import { PriceService } from "../products/prices/PriceService.js";
|
||||
import { OrgService } from "../orgs/OrgService.js";
|
||||
import { formatPrice } from "../products/prices/priceUtils.js";
|
||||
import { ProductService } from "../products/ProductService.js";
|
||||
|
||||
// Helper function to check if tier structures match
|
||||
const tiersMatch = (oldTiers: any[], newTiers: any[]): boolean => {
|
||||
if (oldTiers.length !== newTiers.length) return false;
|
||||
|
||||
return oldTiers.every((oldTier, index) => {
|
||||
const newTier = newTiers[index];
|
||||
return oldTier.to === newTier.to && oldTier.amount === newTier.amount;
|
||||
});
|
||||
};
|
||||
|
||||
// Match fixed prices by amount
|
||||
const findMatchingFixedPrice = (
|
||||
oldPrice: Price,
|
||||
candidates: Price[]
|
||||
): Price | null => {
|
||||
const oldConfig = oldPrice.config as FixedPriceConfig;
|
||||
|
||||
const possibleCandidate = candidates.find((candidate) => {
|
||||
const newConfig = candidate.config as FixedPriceConfig;
|
||||
return newConfig.amount === oldConfig.amount;
|
||||
});
|
||||
|
||||
return possibleCandidate || candidates?.[0];
|
||||
};
|
||||
|
||||
// Match usage prices by feature and billing characteristics
|
||||
const findMatchingUsagePrice = (
|
||||
oldPrice: Price,
|
||||
candidates: Price[]
|
||||
): Price | null => {
|
||||
const oldConfig = oldPrice.config as UsagePriceConfig;
|
||||
|
||||
return (
|
||||
candidates.find((candidate) => {
|
||||
const newConfig = candidate.config as UsagePriceConfig;
|
||||
|
||||
// Match by feature
|
||||
if (newConfig.internal_feature_id !== oldConfig.internal_feature_id)
|
||||
return false;
|
||||
|
||||
// Match by billing behavior
|
||||
let newBillingType = getBillingType(newConfig);
|
||||
let oldBillingType = getBillingType(oldConfig);
|
||||
if (newBillingType !== oldBillingType) return false;
|
||||
|
||||
// Optionally match by tier structure
|
||||
// if (!tiersMatch(oldConfig.usage_tiers, newConfig.usage_tiers))
|
||||
if (!tiersAreSame(oldConfig.usage_tiers, newConfig.usage_tiers))
|
||||
return false;
|
||||
|
||||
return true;
|
||||
}) || null
|
||||
);
|
||||
};
|
||||
|
||||
// Main matching function with type-specific logic
|
||||
const findBestMatch = (oldPrice: Price, newPrices: Price[]): Price | null => {
|
||||
// First, filter by basic characteristics
|
||||
|
||||
const candidates = newPrices.filter((newPrice) => {
|
||||
if (newPrice.id === oldPrice.id) return true;
|
||||
|
||||
const oldConfig = oldPrice.config as UsagePriceConfig;
|
||||
const newConfig = newPrice.config as UsagePriceConfig;
|
||||
|
||||
return (
|
||||
getBillingType(newPrice.config) === getBillingType(oldPrice.config) &&
|
||||
newPrice.config.interval === oldPrice.config.interval &&
|
||||
newPrice.config.interval_count === oldPrice.config.interval_count &&
|
||||
(oldConfig.type == PriceType.Usage
|
||||
? oldConfig.internal_feature_id === newConfig.internal_feature_id
|
||||
: true)
|
||||
);
|
||||
});
|
||||
|
||||
if (candidates.length === 0) return null;
|
||||
if (candidates.length === 1) return candidates[0];
|
||||
|
||||
// If multiple candidates, use type-specific matching
|
||||
if (isFixedPrice({ price: oldPrice })) {
|
||||
return findMatchingFixedPrice(oldPrice, candidates);
|
||||
} else if (isUsagePrice({ price: oldPrice })) {
|
||||
return findMatchingUsagePrice(oldPrice, candidates);
|
||||
}
|
||||
|
||||
// Fallback to first candidate
|
||||
return candidates[0];
|
||||
};
|
||||
|
||||
export async function runRewardMigrationTask({
|
||||
db,
|
||||
payload,
|
||||
logger,
|
||||
}: {
|
||||
db: DrizzleCli;
|
||||
payload: Payloads[JobName.RewardMigration];
|
||||
logger: ReturnType<typeof loggerType.child>;
|
||||
}) {
|
||||
try {
|
||||
const {
|
||||
oldPrices,
|
||||
productId,
|
||||
// newPrices,
|
||||
orgId,
|
||||
env,
|
||||
}: {
|
||||
oldPrices: Price[];
|
||||
// newPrices: Price[];
|
||||
productId: string;
|
||||
orgId: string;
|
||||
env: AppEnv;
|
||||
} = payload;
|
||||
|
||||
const fullProduct = await ProductService.getFull({
|
||||
db,
|
||||
idOrInternalId: productId,
|
||||
orgId,
|
||||
env,
|
||||
});
|
||||
|
||||
const newPrices = fullProduct.prices;
|
||||
|
||||
// Get organization for Stripe operations
|
||||
const org = await OrgService.get({
|
||||
db,
|
||||
orgId,
|
||||
});
|
||||
|
||||
const rewards = await RewardService.list({
|
||||
db,
|
||||
orgId,
|
||||
env,
|
||||
inTypes: [
|
||||
RewardType.PercentageDiscount,
|
||||
RewardType.FixedDiscount,
|
||||
RewardType.InvoiceCredits,
|
||||
],
|
||||
});
|
||||
|
||||
const filteredRewards = rewards.filter(
|
||||
(x) =>
|
||||
x.org_id === orgId &&
|
||||
x.env === env &&
|
||||
x.type !== RewardType.FreeProduct &&
|
||||
x.discount_config &&
|
||||
x.discount_config.price_ids?.some((p) =>
|
||||
oldPrices.map((p) => p.id).includes(p)
|
||||
)
|
||||
);
|
||||
|
||||
let shouldUpdateReward = false;
|
||||
|
||||
for (const reward of filteredRewards) {
|
||||
const newPriceIds: string[] = [];
|
||||
const unmatchedPrices: string[] = [];
|
||||
|
||||
if (reward.discount_config?.price_ids) {
|
||||
for (const priceId of reward.discount_config.price_ids) {
|
||||
const oldPrice = oldPrices.find((p) => p.id === priceId);
|
||||
|
||||
// From other product
|
||||
if (!oldPrice) {
|
||||
newPriceIds.push(priceId);
|
||||
continue;
|
||||
}
|
||||
|
||||
const matchingNewPrice = findBestMatch(oldPrice, newPrices);
|
||||
|
||||
if (matchingNewPrice) {
|
||||
newPriceIds.push(matchingNewPrice.id);
|
||||
const shouldUpdate =
|
||||
matchingNewPrice.config.stripe_price_id !==
|
||||
oldPrice.config.stripe_price_id ||
|
||||
matchingNewPrice.config.stripe_product_id !==
|
||||
oldPrice.config.stripe_product_id;
|
||||
|
||||
if (shouldUpdate) {
|
||||
shouldUpdateReward = true;
|
||||
}
|
||||
} else {
|
||||
unmatchedPrices.push(oldPrice.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update the reward with new price IDs
|
||||
if (shouldUpdateReward) {
|
||||
try {
|
||||
// Update Stripe coupon and reward if price IDs have changed
|
||||
console.log(
|
||||
`Updating ${reward.id}, updating reward and Stripe coupon...`
|
||||
);
|
||||
|
||||
// Update the reward in the database
|
||||
const updatedReward = await RewardService.update({
|
||||
db,
|
||||
internalId: reward.internal_id!,
|
||||
env,
|
||||
orgId,
|
||||
update: {
|
||||
discount_config: {
|
||||
...(reward.discount_config as DiscountConfig),
|
||||
price_ids: newPriceIds,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Get the price objects for the new price IDs
|
||||
const prices = await PriceService.getInIds({
|
||||
db,
|
||||
ids: newPriceIds,
|
||||
});
|
||||
|
||||
// Recreate the Stripe coupon with new product restrictions
|
||||
await createStripeCoupon({
|
||||
reward: updatedReward,
|
||||
org,
|
||||
env,
|
||||
prices,
|
||||
logger,
|
||||
});
|
||||
|
||||
console.log(
|
||||
`Successfully updated Stripe coupon for reward ${reward.id} with new product restrictions`
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(`Failed to update reward ${reward.id}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
if (unmatchedPrices.length > 0) {
|
||||
console.warn(
|
||||
`Unmatched prices for reward ${reward.id}:`,
|
||||
unmatchedPrices
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error running reward migration task", { error });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -14,6 +14,8 @@ import {
|
||||
Price,
|
||||
FullProduct,
|
||||
FullEntitlement,
|
||||
Rollover,
|
||||
RolloverConfig,
|
||||
} from "@autumn/shared";
|
||||
|
||||
import { addDays } from "date-fns";
|
||||
@@ -76,6 +78,24 @@ export const addTrialToNextResetAt = (
|
||||
return addDays(new Date(nextResetAt), freeTrial.length).getTime();
|
||||
};
|
||||
|
||||
export const rolloversAreSame = ({
|
||||
rollover1,
|
||||
rollover2,
|
||||
}: {
|
||||
rollover1?: RolloverConfig | null;
|
||||
rollover2?: RolloverConfig | null;
|
||||
}) => {
|
||||
if (!rollover1 && !rollover2) return true;
|
||||
if (!rollover1 && rollover2) return false;
|
||||
if (rollover1 && !rollover2) return false;
|
||||
|
||||
return (
|
||||
rollover1!.max == rollover2!.max &&
|
||||
rollover1!.duration == rollover2!.duration &&
|
||||
rollover1!.length == rollover2!.length
|
||||
);
|
||||
};
|
||||
|
||||
export const entsAreSame = (ent1: Entitlement, ent2: Entitlement) => {
|
||||
// 1. Check if they have same internal_feature_id
|
||||
if (ent1.internal_feature_id !== ent2.internal_feature_id) {
|
||||
@@ -121,23 +141,25 @@ export const entsAreSame = (ent1: Entitlement, ent2: Entitlement) => {
|
||||
message: `Usage limit different: ${ent1.usage_limit} !== ${ent2.usage_limit}`,
|
||||
},
|
||||
rollover: {
|
||||
condition:
|
||||
JSON.stringify(ent1.rollover) !== JSON.stringify(ent2.rollover),
|
||||
condition: !rolloversAreSame({
|
||||
rollover1: ent1.rollover,
|
||||
rollover2: ent2.rollover,
|
||||
}),
|
||||
message: `Rollover different: ${ent1.rollover} !== ${ent2.rollover}`,
|
||||
},
|
||||
};
|
||||
|
||||
let entsAreDiff = Object.values(diffs).some((d) => d.condition);
|
||||
|
||||
// if (entsAreDiff) {
|
||||
// console.log("Entitlements different");
|
||||
// console.log(
|
||||
// "Differences:",
|
||||
// Object.values(diffs)
|
||||
// .filter((d) => d.condition)
|
||||
// .map((d) => d.message),
|
||||
// );
|
||||
// }
|
||||
if (entsAreDiff) {
|
||||
console.log("Entitlements different");
|
||||
console.log(
|
||||
"Differences:",
|
||||
Object.values(diffs)
|
||||
.filter((d) => d.condition)
|
||||
.map((d) => d.message)
|
||||
);
|
||||
}
|
||||
return !entsAreDiff;
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,28 +1,28 @@
|
||||
import RecaseError from "@/utils/errorUtils.js";
|
||||
import { ErrCode, FullProduct, UpdateProductSchema } from "@autumn/shared";
|
||||
|
||||
import { ProductService } from "../../ProductService.js";
|
||||
import { notNullish } from "@/utils/genUtils.js";
|
||||
import { ErrCode, type FullProduct, UpdateProductSchema } from "@autumn/shared";
|
||||
import { CusProductService } from "@/internal/customers/cusProducts/CusProductService.js";
|
||||
import { FeatureService } from "@/internal/features/FeatureService.js";
|
||||
import { OrgService } from "@/internal/orgs/OrgService.js";
|
||||
|
||||
import { handleNewFreeTrial } from "@/internal/products/free-trials/freeTrialUtils.js";
|
||||
import { CusProductService } from "@/internal/customers/cusProducts/CusProductService.js";
|
||||
import { handleVersionProductV2 } from "../handleVersionProduct.js";
|
||||
import { routeHandler } from "@/utils/routerUtils.js";
|
||||
import { handleNewProductItems } from "@/internal/products/product-items/productItemUtils/handleNewProductItems.js";
|
||||
import { RewardProgramService } from "@/internal/rewards/RewardProgramService.js";
|
||||
import { handleUpdateProductDetails } from "./updateProductDetails.js";
|
||||
import { addTaskToQueue } from "@/queue/queueUtils.js";
|
||||
import { JobName } from "@/queue/JobName.js";
|
||||
import { addTaskToQueue } from "@/queue/queueUtils.js";
|
||||
import RecaseError from "@/utils/errorUtils.js";
|
||||
import { notNullish } from "@/utils/genUtils.js";
|
||||
import { routeHandler } from "@/utils/routerUtils.js";
|
||||
import { getEntsWithFeature } from "../../entitlements/entitlementUtils.js";
|
||||
import { validateOneOffTrial } from "../../free-trials/freeTrialUtils.js";
|
||||
import { ProductService } from "../../ProductService.js";
|
||||
import { productsAreSame } from "../../productUtils/compareProductUtils.js";
|
||||
import { initProductInStripe } from "../../productUtils.js";
|
||||
import { mapToProductItems } from "../../productV2Utils.js";
|
||||
import {
|
||||
disableCurrentDefault,
|
||||
handleCreateProduct,
|
||||
} from "../handleCreateProduct.js";
|
||||
import { mapToProductItems } from "../../productV2Utils.js";
|
||||
import { validateOneOffTrial } from "../../free-trials/freeTrialUtils.js";
|
||||
import { handleVersionProductV2 } from "../handleVersionProduct.js";
|
||||
import { handleUpdateProductDetails } from "./updateProductDetails.js";
|
||||
import { formatPrice } from "../../prices/priceUtils.js";
|
||||
|
||||
export const handleUpdateProductV2 = async (req: any, res: any) =>
|
||||
routeHandler({
|
||||
@@ -34,7 +34,7 @@ export const handleUpdateProductV2 = async (req: any, res: any) =>
|
||||
const { version, upsert, disable_version } = req.query;
|
||||
const { orgId, env, logger, db } = req;
|
||||
|
||||
const [features, org, fullProduct, rewardPrograms, defaultProds] =
|
||||
const [features, org, fullProduct, rewardPrograms, _defaultProds] =
|
||||
await Promise.all([
|
||||
FeatureService.getFromReq(req),
|
||||
OrgService.getFromReq(req),
|
||||
@@ -44,7 +44,7 @@ export const handleUpdateProductV2 = async (req: any, res: any) =>
|
||||
orgId,
|
||||
env,
|
||||
version: version ? parseInt(version) : undefined,
|
||||
allowNotFound: upsert == "true",
|
||||
allowNotFound: upsert === "true",
|
||||
}),
|
||||
RewardProgramService.getByProductId({
|
||||
db,
|
||||
@@ -60,7 +60,7 @@ export const handleUpdateProductV2 = async (req: any, res: any) =>
|
||||
]);
|
||||
|
||||
if (!fullProduct) {
|
||||
if (upsert == "true") {
|
||||
if (upsert === "true") {
|
||||
await handleCreateProduct(req, res);
|
||||
return;
|
||||
}
|
||||
@@ -78,7 +78,7 @@ export const handleUpdateProductV2 = async (req: any, res: any) =>
|
||||
internalProductId: fullProduct.internal_id,
|
||||
});
|
||||
|
||||
let cusProductExists = cusProductsCurVersion.length > 0;
|
||||
const cusProductExists = cusProductsCurVersion.length > 0;
|
||||
|
||||
// console.log("Updating product", {
|
||||
// id: fullProduct.id,
|
||||
@@ -111,15 +111,14 @@ export const handleUpdateProductV2 = async (req: any, res: any) =>
|
||||
logger,
|
||||
});
|
||||
|
||||
let itemsExist = notNullish(req.body.items);
|
||||
const itemsExist = notNullish(req.body.items);
|
||||
if (cusProductExists && itemsExist) {
|
||||
if (disable_version == "true") {
|
||||
if (disable_version === "true") {
|
||||
throw new RecaseError({
|
||||
message: "Cannot auto save product as there are existing customers",
|
||||
code: ErrCode.InvalidRequest,
|
||||
statusCode: 400,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const { itemsSame, freeTrialsSame } = productsAreSame({
|
||||
@@ -154,7 +153,7 @@ export const handleUpdateProductV2 = async (req: any, res: any) =>
|
||||
});
|
||||
}
|
||||
|
||||
const { prices, entitlements } = await handleNewProductItems({
|
||||
await handleNewProductItems({
|
||||
db,
|
||||
curPrices: fullProduct.prices,
|
||||
curEnts: fullProduct.entitlements,
|
||||
@@ -165,9 +164,17 @@ export const handleUpdateProductV2 = async (req: any, res: any) =>
|
||||
isCustom: false,
|
||||
});
|
||||
|
||||
// New full product
|
||||
const newFullProduct = await ProductService.getFull({
|
||||
db,
|
||||
idOrInternalId: fullProduct.id,
|
||||
orgId,
|
||||
env,
|
||||
});
|
||||
|
||||
if (free_trial !== undefined) {
|
||||
await validateOneOffTrial({
|
||||
prices,
|
||||
prices: newFullProduct.prices,
|
||||
freeTrial: free_trial,
|
||||
});
|
||||
|
||||
@@ -181,13 +188,10 @@ export const handleUpdateProductV2 = async (req: any, res: any) =>
|
||||
});
|
||||
}
|
||||
|
||||
// New full product
|
||||
await initProductInStripe({
|
||||
db,
|
||||
product: {
|
||||
...fullProduct,
|
||||
prices,
|
||||
entitlements,
|
||||
} as FullProduct,
|
||||
product: newFullProduct,
|
||||
org,
|
||||
env,
|
||||
logger,
|
||||
@@ -197,14 +201,19 @@ export const handleUpdateProductV2 = async (req: any, res: any) =>
|
||||
await addTaskToQueue({
|
||||
jobName: JobName.DetectBaseVariant,
|
||||
payload: {
|
||||
curProduct: {
|
||||
...fullProduct,
|
||||
prices: prices.length > 0 ? prices : fullProduct.prices,
|
||||
entitlements,
|
||||
},
|
||||
curProduct: newFullProduct,
|
||||
},
|
||||
});
|
||||
|
||||
await addTaskToQueue({
|
||||
jobName: JobName.RewardMigration,
|
||||
payload: {
|
||||
oldPrices: fullProduct.prices,
|
||||
productId: fullProduct.id,
|
||||
orgId: org.id,
|
||||
env,
|
||||
},
|
||||
});
|
||||
res.status(200).send({ message: "Product updated" });
|
||||
return;
|
||||
},
|
||||
|
||||
@@ -1,142 +1,156 @@
|
||||
import {
|
||||
type AppEnv,
|
||||
CreateProductSchema,
|
||||
type FreeTrial,
|
||||
type FullProduct,
|
||||
type Organization,
|
||||
type ProductItem,
|
||||
} from "@autumn/shared";
|
||||
import { FeatureService } from "@/internal/features/FeatureService.js";
|
||||
import { EntitlementService } from "@/internal/products/entitlements/EntitlementService.js";
|
||||
import { handleNewFreeTrial } from "@/internal/products/free-trials/freeTrialUtils.js";
|
||||
import { ProductService } from "@/internal/products/ProductService.js";
|
||||
import { PriceService } from "@/internal/products/prices/PriceService.js";
|
||||
import { handleNewProductItems } from "@/internal/products/product-items/productItemUtils/handleNewProductItems.js";
|
||||
import { validateProductItems } from "@/internal/products/product-items/validateProductItems.js";
|
||||
import {
|
||||
constructProduct,
|
||||
initProductInStripe,
|
||||
} from "@/internal/products/productUtils.js";
|
||||
import {
|
||||
AppEnv,
|
||||
CreateProductSchema,
|
||||
FreeTrial,
|
||||
Organization,
|
||||
ProductItem,
|
||||
} from "@autumn/shared";
|
||||
|
||||
import { FullProduct } from "@autumn/shared";
|
||||
import { handleNewProductItems } from "@/internal/products/product-items/productItemUtils/handleNewProductItems.js";
|
||||
import { validateProductItems } from "@/internal/products/product-items/validateProductItems.js";
|
||||
import { EntitlementService } from "@/internal/products/entitlements/EntitlementService.js";
|
||||
import { PriceService } from "@/internal/products/prices/PriceService.js";
|
||||
import { addTaskToQueue } from "@/queue/queueUtils.js";
|
||||
import { JobName } from "@/queue/JobName.js";
|
||||
import { addTaskToQueue } from "@/queue/queueUtils.js";
|
||||
import { getEntsWithFeature } from "../entitlements/entitlementUtils.js";
|
||||
|
||||
export const handleVersionProductV2 = async ({
|
||||
req,
|
||||
res,
|
||||
latestProduct,
|
||||
org,
|
||||
env,
|
||||
items,
|
||||
freeTrial,
|
||||
req,
|
||||
res,
|
||||
latestProduct,
|
||||
org,
|
||||
env,
|
||||
items,
|
||||
freeTrial,
|
||||
}: {
|
||||
req: any;
|
||||
res: any;
|
||||
latestProduct: FullProduct;
|
||||
org: Organization;
|
||||
env: AppEnv;
|
||||
items: ProductItem[];
|
||||
freeTrial: FreeTrial;
|
||||
req: any;
|
||||
res: any;
|
||||
latestProduct: FullProduct;
|
||||
org: Organization;
|
||||
env: AppEnv;
|
||||
items: ProductItem[];
|
||||
freeTrial: FreeTrial;
|
||||
}) => {
|
||||
const { db } = req;
|
||||
const { db } = req;
|
||||
|
||||
let curVersion = latestProduct.version;
|
||||
let newVersion = curVersion + 1;
|
||||
const curVersion = latestProduct.version;
|
||||
const newVersion = curVersion + 1;
|
||||
|
||||
let features = await FeatureService.getFromReq(req);
|
||||
const features = await FeatureService.getFromReq(req);
|
||||
|
||||
console.log(
|
||||
`Updating product ${latestProduct.id} version from ${curVersion} to ${newVersion}`
|
||||
);
|
||||
console.log(
|
||||
`Updating product ${latestProduct.id} version from ${curVersion} to ${newVersion}`,
|
||||
);
|
||||
|
||||
const newProduct = constructProduct({
|
||||
productData: CreateProductSchema.parse({
|
||||
...latestProduct,
|
||||
...req.body,
|
||||
version: newVersion,
|
||||
}),
|
||||
orgId: org.id,
|
||||
env: latestProduct.env as AppEnv,
|
||||
processor: latestProduct.processor,
|
||||
baseVariantId: latestProduct.base_variant_id,
|
||||
});
|
||||
const newProduct = constructProduct({
|
||||
productData: CreateProductSchema.parse({
|
||||
...latestProduct,
|
||||
...req.body,
|
||||
version: newVersion,
|
||||
}),
|
||||
orgId: org.id,
|
||||
env: latestProduct.env as AppEnv,
|
||||
processor: latestProduct.processor,
|
||||
baseVariantId: latestProduct.base_variant_id,
|
||||
});
|
||||
|
||||
// Validate product items...
|
||||
validateProductItems({
|
||||
newItems: items,
|
||||
features,
|
||||
orgId: org.id,
|
||||
env,
|
||||
});
|
||||
// Validate product items...
|
||||
validateProductItems({
|
||||
newItems: items,
|
||||
features,
|
||||
orgId: org.id,
|
||||
env,
|
||||
});
|
||||
|
||||
if (latestProduct.is_default) {
|
||||
await ProductService.updateByInternalId({
|
||||
db,
|
||||
internalId: latestProduct.internal_id,
|
||||
update: {
|
||||
is_default: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
if (latestProduct.is_default) {
|
||||
await ProductService.updateByInternalId({
|
||||
db,
|
||||
internalId: latestProduct.internal_id,
|
||||
update: {
|
||||
is_default: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await ProductService.insert({ db, product: newProduct });
|
||||
await ProductService.insert({ db, product: newProduct });
|
||||
|
||||
const { customPrices, customEnts } = await handleNewProductItems({
|
||||
db,
|
||||
curPrices: latestProduct.prices,
|
||||
curEnts: latestProduct.entitlements,
|
||||
newItems: items,
|
||||
features,
|
||||
product: newProduct,
|
||||
logger: console,
|
||||
isCustom: false,
|
||||
newVersion: true,
|
||||
});
|
||||
const { customPrices, customEnts } = await handleNewProductItems({
|
||||
db,
|
||||
curPrices: latestProduct.prices,
|
||||
curEnts: latestProduct.entitlements,
|
||||
newItems: items,
|
||||
features,
|
||||
product: newProduct,
|
||||
logger: console,
|
||||
isCustom: false,
|
||||
newVersion: true,
|
||||
});
|
||||
|
||||
await EntitlementService.insert({
|
||||
db,
|
||||
data: customEnts,
|
||||
});
|
||||
await EntitlementService.insert({
|
||||
db,
|
||||
data: customEnts,
|
||||
});
|
||||
|
||||
await PriceService.insert({
|
||||
db,
|
||||
data: customPrices,
|
||||
});
|
||||
await PriceService.insert({
|
||||
db,
|
||||
data: customPrices,
|
||||
});
|
||||
|
||||
// Handle new free trial
|
||||
if (freeTrial) {
|
||||
await handleNewFreeTrial({
|
||||
db,
|
||||
newFreeTrial: freeTrial,
|
||||
curFreeTrial: null,
|
||||
internalProductId: newProduct.internal_id,
|
||||
isCustom: false,
|
||||
});
|
||||
}
|
||||
// Handle new free trial
|
||||
if (freeTrial) {
|
||||
await handleNewFreeTrial({
|
||||
db,
|
||||
newFreeTrial: freeTrial,
|
||||
curFreeTrial: null,
|
||||
internalProductId: newProduct.internal_id,
|
||||
isCustom: false,
|
||||
});
|
||||
}
|
||||
|
||||
// await addTaskToQueue({
|
||||
// jobName: JobName.DetectBaseVariant,
|
||||
// payload: {
|
||||
// curProduct: {
|
||||
// ...newProduct,
|
||||
// // prices: customPrices,
|
||||
// // entitlements: getEntsWithFeature({ ents: customEnts, features }),
|
||||
// },
|
||||
// },
|
||||
// });
|
||||
// await addTaskToQueue({
|
||||
// jobName: JobName.DetectBaseVariant,
|
||||
// payload: {
|
||||
// curProduct: {
|
||||
// ...newProduct,
|
||||
// // prices: customPrices,
|
||||
// // entitlements: getEntsWithFeature({ ents: customEnts, features }),
|
||||
// },
|
||||
// },
|
||||
// });
|
||||
|
||||
await initProductInStripe({
|
||||
db,
|
||||
product: {
|
||||
...newProduct,
|
||||
prices: customPrices,
|
||||
entitlements: getEntsWithFeature({ ents: customEnts, features }),
|
||||
} as FullProduct,
|
||||
org,
|
||||
env,
|
||||
logger: console,
|
||||
});
|
||||
await initProductInStripe({
|
||||
db,
|
||||
product: {
|
||||
...newProduct,
|
||||
prices: customPrices,
|
||||
entitlements: getEntsWithFeature({ ents: customEnts, features }),
|
||||
} as FullProduct,
|
||||
org,
|
||||
env,
|
||||
logger: console,
|
||||
});
|
||||
|
||||
res.status(200).send(newProduct);
|
||||
await addTaskToQueue({
|
||||
jobName: JobName.RewardMigration,
|
||||
payload: {
|
||||
oldPrices: latestProduct.prices,
|
||||
newPrices: customPrices,
|
||||
product: {
|
||||
...newProduct,
|
||||
prices: customPrices,
|
||||
entitlements: getEntsWithFeature({ ents: customEnts, features }),
|
||||
},
|
||||
orgId: org.id,
|
||||
env,
|
||||
},
|
||||
});
|
||||
|
||||
res.status(200).send(newProduct);
|
||||
};
|
||||
|
||||
@@ -14,13 +14,13 @@ import { RewardProgramService } from "../rewards/RewardProgramService.js";
|
||||
import { mapToProductV2 } from "./productV2Utils.js";
|
||||
import { isFeaturePriceItem } from "./product-items/productItemUtils/getItemType.js";
|
||||
|
||||
import RecaseError, {
|
||||
handleFrontendReqError,
|
||||
handleRequestError,
|
||||
} from "@/utils/errorUtils.js";
|
||||
import RecaseError, { handleFrontendReqError } from "@/utils/errorUtils.js";
|
||||
|
||||
import { createOrgResponse } from "../orgs/orgUtils.js";
|
||||
import { sortFullProducts } from "./productUtils/sortProductUtils.js";
|
||||
import {
|
||||
sortFullProducts,
|
||||
sortProductsByPrice,
|
||||
} from "./productUtils/sortProductUtils.js";
|
||||
import { handleGetProductDeleteInfo } from "./handlers/handleGetProductDeleteInfo.js";
|
||||
|
||||
export const productRouter: Router = Router({ mergeParams: true });
|
||||
@@ -35,6 +35,8 @@ productRouter.get("/products", async (req: any, res) => {
|
||||
env: req.env,
|
||||
});
|
||||
|
||||
sortFullProducts({ products });
|
||||
|
||||
const groupToDefaults = getGroupToDefaults({
|
||||
defaultProds: products,
|
||||
});
|
||||
|
||||
@@ -13,6 +13,7 @@ import { validateProductItems } from "../validateProductItems.js";
|
||||
import { FeatureService } from "@/internal/features/FeatureService.js";
|
||||
import { isFeatureItem } from "./getItemType.js";
|
||||
import { DrizzleCli } from "@/db/initDrizzle.js";
|
||||
import { formatPrice } from "../../prices/priceUtils.js";
|
||||
|
||||
const updateDbPricesAndEnts = async ({
|
||||
db,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import RecaseError from "@/utils/errorUtils.js";
|
||||
import {
|
||||
AllowanceType,
|
||||
BillingInterval,
|
||||
@@ -19,13 +20,12 @@ import {
|
||||
OnIncrease,
|
||||
OnDecrease,
|
||||
FeatureUsageType,
|
||||
features,
|
||||
} from "@autumn/shared";
|
||||
import { generateId, notNullish, nullish } from "@/utils/genUtils.js";
|
||||
import { pricesAreSame } from "@/internal/products/prices/priceInitUtils.js";
|
||||
import { entsAreSame } from "../../entitlements/entitlementUtils.js";
|
||||
import { getBillingType } from "@/internal/products/prices/priceUtils.js";
|
||||
import RecaseError from "@/utils/errorUtils.js";
|
||||
|
||||
import {
|
||||
isFeatureItem,
|
||||
isFeaturePriceItem,
|
||||
|
||||
@@ -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,12 +237,14 @@ export const checkStripeProductExists = async ({
|
||||
createNew = true;
|
||||
} else {
|
||||
try {
|
||||
let stripeProduct = await stripeCli.products.retrieve(
|
||||
const stripeProduct = await stripeCli.products.retrieve(
|
||||
product.processor!.id
|
||||
);
|
||||
|
||||
if (!stripeProduct.active) {
|
||||
createNew = true;
|
||||
await stripeCli.products.update(product.processor!.id, {
|
||||
active: true,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
createNew = true;
|
||||
@@ -289,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;
|
||||
@@ -341,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[] = [];
|
||||
@@ -349,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({
|
||||
@@ -364,7 +365,7 @@ export const copyProduct = async ({
|
||||
});
|
||||
}
|
||||
|
||||
let newId = generateId("ent");
|
||||
const newId = generateId("ent");
|
||||
newEntitlements.push(
|
||||
EntitlementSchema.parse({
|
||||
...entitlement,
|
||||
@@ -380,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;
|
||||
@@ -394,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({
|
||||
@@ -412,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}`,
|
||||
@@ -486,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,
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { AppEnv, ErrCode, Reward, rewards } from "@autumn/shared";
|
||||
import { DrizzleCli } from "@/db/initDrizzle.js";
|
||||
import {
|
||||
type AppEnv,
|
||||
ErrCode,
|
||||
type Reward,
|
||||
rewards,
|
||||
RewardType,
|
||||
} 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({
|
||||
@@ -15,7 +21,7 @@ export class RewardService {
|
||||
orgId: string;
|
||||
env: AppEnv;
|
||||
}) {
|
||||
let result = await db.query.rewards.findFirst({
|
||||
const result = await db.query.rewards.findFirst({
|
||||
where: and(
|
||||
or(
|
||||
eq(rewards.id, idOrInternalId),
|
||||
@@ -44,7 +50,7 @@ export class RewardService {
|
||||
orgId: string;
|
||||
env: AppEnv;
|
||||
}) {
|
||||
let reward = await db.query.rewards.findMany({
|
||||
const reward = await db.query.rewards.findMany({
|
||||
where: and(
|
||||
eq(rewards.org_id, orgId),
|
||||
eq(rewards.env, env),
|
||||
@@ -70,7 +76,7 @@ export class RewardService {
|
||||
db: DrizzleCli;
|
||||
data: Reward | Reward[];
|
||||
}) {
|
||||
let results = await db.insert(rewards).values(data as Reward);
|
||||
const results = await db.insert(rewards).values(data as Reward);
|
||||
return results as Reward[];
|
||||
}
|
||||
|
||||
@@ -78,13 +84,19 @@ export class RewardService {
|
||||
db,
|
||||
orgId,
|
||||
env,
|
||||
inTypes,
|
||||
}: {
|
||||
db: DrizzleCli;
|
||||
orgId: string;
|
||||
env: AppEnv;
|
||||
inTypes?: RewardType[];
|
||||
}) {
|
||||
let results = await db.query.rewards.findMany({
|
||||
where: and(eq(rewards.org_id, orgId), eq(rewards.env, env)),
|
||||
const results = await db.query.rewards.findMany({
|
||||
where: and(
|
||||
eq(rewards.org_id, orgId),
|
||||
eq(rewards.env, env),
|
||||
inTypes ? inArray(rewards.type, inTypes) : undefined
|
||||
),
|
||||
orderBy: [desc(rewards.internal_id)],
|
||||
});
|
||||
|
||||
@@ -126,7 +138,7 @@ export class RewardService {
|
||||
orgId: string;
|
||||
update: Partial<Reward>;
|
||||
}) {
|
||||
let result = await db
|
||||
const result = await db
|
||||
.update(rewards)
|
||||
.set(update)
|
||||
.where(
|
||||
|
||||
@@ -1,31 +1,43 @@
|
||||
import {
|
||||
AppEnv,
|
||||
CusProductStatus,
|
||||
Customer,
|
||||
type AppEnv,
|
||||
AttachBranch,
|
||||
type Customer,
|
||||
ErrCode,
|
||||
FullRewardProgram,
|
||||
ReferralCode,
|
||||
Reward,
|
||||
RewardCategory,
|
||||
type FullRewardProgram,
|
||||
type ReferralCode,
|
||||
type Reward,
|
||||
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 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 { 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";
|
||||
@@ -62,7 +74,7 @@ export const triggerRedemption = async ({
|
||||
`Triggering redemption ${redemption.id} for referral code ${referralCode.code}`
|
||||
);
|
||||
|
||||
let applyToCustomer = await CusService.getByInternalId({
|
||||
const applyToCustomer = await CusService.getByInternalId({
|
||||
db,
|
||||
internalId: referralCode.internal_customer_id,
|
||||
});
|
||||
@@ -75,7 +87,7 @@ export const triggerRedemption = async ({
|
||||
});
|
||||
}
|
||||
|
||||
let stripeCli = createStripeCli({
|
||||
const stripeCli = createStripeCli({
|
||||
org,
|
||||
env,
|
||||
legacyVersion: true,
|
||||
@@ -89,15 +101,15 @@ export const triggerRedemption = async ({
|
||||
logger,
|
||||
});
|
||||
|
||||
let stripeCusId = applyToCustomer.processor.id;
|
||||
let stripeCus = (await stripeCli.customers.retrieve(
|
||||
const stripeCusId = applyToCustomer.processor.id;
|
||||
const stripeCus = (await stripeCli.customers.retrieve(
|
||||
stripeCusId
|
||||
)) as Stripe.Customer;
|
||||
|
||||
let applied = false;
|
||||
if (!stripeCus.discount) {
|
||||
await stripeCli.customers.update(stripeCusId, {
|
||||
// @ts-ignore
|
||||
// @ts-expect-error
|
||||
coupon: reward.id,
|
||||
});
|
||||
|
||||
@@ -105,7 +117,7 @@ export const triggerRedemption = async ({
|
||||
logger.info(`Applied coupon to customer in Stripe`);
|
||||
}
|
||||
|
||||
let updatedRedemption = await RewardRedemptionService.update({
|
||||
const updatedRedemption = await RewardRedemptionService.update({
|
||||
db,
|
||||
id: redemption.id,
|
||||
updates: {
|
||||
@@ -118,143 +130,3 @@ export const triggerRedemption = async ({
|
||||
|
||||
return updatedRedemption;
|
||||
};
|
||||
|
||||
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({
|
||||
db,
|
||||
id: redemption.id,
|
||||
updates: {
|
||||
triggered: true,
|
||||
applied: true,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
};
|
||||
208
server/src/internal/rewards/referralUtils/triggerFreeProduct.ts
Normal file
208
server/src/internal/rewards/referralUtils/triggerFreeProduct.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -1,115 +1,137 @@
|
||||
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,
|
||||
logger,
|
||||
db,
|
||||
payload,
|
||||
logger,
|
||||
}: {
|
||||
db: DrizzleCli;
|
||||
payload: any;
|
||||
logger: any;
|
||||
db: DrizzleCli;
|
||||
payload: any;
|
||||
logger: any;
|
||||
}) => {
|
||||
try {
|
||||
// Customer redeeming code, product they're buying
|
||||
let { customer, product, org, env, subId } = payload;
|
||||
try {
|
||||
// Customer redeeming code, product they're buying
|
||||
const { customer, product, org, env, subId } = payload;
|
||||
const stripeCli = createStripeCli({
|
||||
org,
|
||||
env,
|
||||
});
|
||||
|
||||
let stripeCli = createStripeCli({
|
||||
org,
|
||||
env,
|
||||
});
|
||||
// 1. Check if redemption exists
|
||||
const redemptions = await RewardRedemptionService.getByCustomer({
|
||||
db,
|
||||
internalCustomerId: customer.internal_id, // customer that redeemed code
|
||||
withRewardProgram: true,
|
||||
triggered: false,
|
||||
withReferralCode: true,
|
||||
triggerWhen: RewardTriggerEvent.Checkout,
|
||||
});
|
||||
|
||||
// 1. Check if redemption exists
|
||||
let redemptions = await RewardRedemptionService.getByCustomer({
|
||||
db,
|
||||
internalCustomerId: customer.internal_id, // customer that redeemed code
|
||||
withRewardProgram: true,
|
||||
triggered: false,
|
||||
withReferralCode: true,
|
||||
triggerWhen: RewardTriggerEvent.Checkout,
|
||||
});
|
||||
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;
|
||||
}
|
||||
|
||||
for (let redemption of redemptions) {
|
||||
if (
|
||||
!redemption ||
|
||||
redemption.reward_program.when !== RewardTriggerEvent.Checkout
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const { reward_program, referral_code: referralCode } =
|
||||
redemption as RewardRedemption & {
|
||||
reward_program: RewardProgram & { reward: Reward };
|
||||
referral_code: ReferralCode;
|
||||
};
|
||||
const { reward } = reward_program;
|
||||
|
||||
let { reward_program, referral_code: referralCode } = redemption;
|
||||
let { reward } = reward_program;
|
||||
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}`,
|
||||
);
|
||||
console.info(`Referral code: ${referralCode.code} (${referralCode.id})`);
|
||||
console.info(
|
||||
`Products: ${reward_program.product_ids?.join(", ")}, ${reward_program.reward.free_product_id}`,
|
||||
);
|
||||
|
||||
logger.info(`--------------------------------`);
|
||||
logger.info(`CHECKING FOR CHECKOUT REWARD, ORG: ${org.slug}`);
|
||||
logger.info(
|
||||
`Redeemed by: ${customer.name} (${customer.id}) for referral program: ${reward_program.id}`
|
||||
);
|
||||
logger.info(`Referral code: ${referralCode.code} (${referralCode.id})`);
|
||||
if (!reward_program.product_ids?.includes(product.id)) {
|
||||
console.info(
|
||||
`Product ${product.name} (${product.id}) not included in referral program, skipping`,
|
||||
);
|
||||
if (reward_program.reward.free_product_id !== product.id) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!reward_program.product_ids.includes(product.id)) {
|
||||
logger.info(
|
||||
`Product ${product.name} (${product.id}) not included in referral program, skipping`
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Check for trial
|
||||
let hasTrial = false;
|
||||
if (subId) {
|
||||
const sub = await stripeCli.subscriptions.retrieve(subId);
|
||||
// hasTrial = Boolean(sub.trial_end && sub.trial_end > Date.now());
|
||||
hasTrial = sub.status === "trialing";
|
||||
}
|
||||
|
||||
// Check for trial
|
||||
let hasTrial = false;
|
||||
if (subId) {
|
||||
let sub = await stripeCli.subscriptions.retrieve(subId);
|
||||
// hasTrial = Boolean(sub.trial_end && sub.trial_end > Date.now());
|
||||
hasTrial = sub.status === "trialing";
|
||||
}
|
||||
if (hasTrial) {
|
||||
console.info(`Subscription is on trial, not triggering reward`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasTrial) {
|
||||
logger.info(`Subscription is on trial, not triggering reward`);
|
||||
return;
|
||||
}
|
||||
// Get redemption count
|
||||
const redemptionCount = await RewardProgramService.getCodeRedemptionCount(
|
||||
{
|
||||
db,
|
||||
referralCodeId: referralCode.id,
|
||||
},
|
||||
);
|
||||
|
||||
// Get redemption count
|
||||
let redemptionCount = await RewardProgramService.getCodeRedemptionCount({
|
||||
db,
|
||||
referralCodeId: referralCode.id,
|
||||
});
|
||||
if (redemptionCount >= reward_program.max_redemptions!) {
|
||||
console.info(
|
||||
`Max redemptions reached, not triggering latest redemption`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (redemptionCount >= reward_program.max_redemptions) {
|
||||
logger.info(
|
||||
`Max redemptions reached, not triggering latest redemption`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let rewardCat = getRewardCat(reward);
|
||||
if (rewardCat === RewardCategory.FreeProduct) {
|
||||
await triggerFreeProduct({
|
||||
db,
|
||||
referralCode,
|
||||
redeemer: customer,
|
||||
rewardProgram: reward_program,
|
||||
org,
|
||||
env,
|
||||
logger,
|
||||
redemption,
|
||||
});
|
||||
} else {
|
||||
await triggerRedemption({
|
||||
db,
|
||||
referralCode,
|
||||
org,
|
||||
env,
|
||||
logger,
|
||||
reward,
|
||||
redemption,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("Failed to trigger checkout reward");
|
||||
logger.error(error);
|
||||
}
|
||||
const rewardCat = getRewardCat(reward);
|
||||
if (rewardCat === RewardCategory.FreeProduct) {
|
||||
await triggerFreeProduct({
|
||||
req: undefined,
|
||||
db,
|
||||
referralCode,
|
||||
redeemer: customer,
|
||||
rewardProgram: reward_program,
|
||||
org,
|
||||
env,
|
||||
logger,
|
||||
redemption,
|
||||
});
|
||||
} else {
|
||||
await triggerRedemption({
|
||||
db,
|
||||
referralCode,
|
||||
org,
|
||||
env,
|
||||
logger,
|
||||
reward,
|
||||
redemption,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to trigger checkout reward");
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@ export enum JobName {
|
||||
UpdateUsage = "update-usage",
|
||||
|
||||
Migration = "migration",
|
||||
RewardMigration = "reward-migration",
|
||||
|
||||
TriggerCheckoutReward = "trigger-checkout-reward",
|
||||
GenerateFeatureDisplay = "generate-feature-display",
|
||||
|
||||
@@ -1,21 +1,35 @@
|
||||
import type { AppEnv, FullProduct, Price } from "@autumn/shared";
|
||||
import RecaseError from "@/utils/errorUtils.js";
|
||||
import { JobName } from "./JobName.js";
|
||||
import { QueueManager } from "./QueueManager.js";
|
||||
|
||||
export const addTaskToQueue = async ({
|
||||
export interface Payloads {
|
||||
[JobName.RewardMigration]: {
|
||||
oldPrices: Price[];
|
||||
productId: string;
|
||||
// newPrices: Price[];
|
||||
// product: FullProduct;
|
||||
orgId: string;
|
||||
env: AppEnv;
|
||||
};
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export const addTaskToQueue = async <T extends keyof Payloads>({
|
||||
jobName,
|
||||
payload,
|
||||
}: {
|
||||
jobName: string;
|
||||
payload: any;
|
||||
jobName: T;
|
||||
payload: Payloads[T];
|
||||
}) => {
|
||||
try {
|
||||
const queue = await QueueManager.getQueue({ useBackup: false });
|
||||
await queue.add(jobName, payload);
|
||||
await queue.add(jobName as string, payload);
|
||||
} catch (error: any) {
|
||||
try {
|
||||
console.log(`Adding ${jobName} to backup queue`);
|
||||
const backupQueue = await QueueManager.getQueue({ useBackup: true });
|
||||
await backupQueue.add(jobName, payload);
|
||||
await backupQueue.add(jobName as string, payload);
|
||||
} catch (error: any) {
|
||||
throw new RecaseError({
|
||||
message: `Failed to add ${jobName} to queue (backup)`,
|
||||
|
||||
@@ -1,21 +1,20 @@
|
||||
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";
|
||||
import { runRewardMigrationTask } from "@/internal/migrations/runRewardMigrationTask.js";
|
||||
|
||||
const NUM_WORKERS = 10;
|
||||
|
||||
@@ -24,7 +23,7 @@ const actionHandlers = [
|
||||
JobName.HandleCustomerCreated,
|
||||
];
|
||||
|
||||
const { db, client } = initDrizzle({ maxConnections: 10 });
|
||||
const { db } = initDrizzle({ maxConnections: 10 });
|
||||
|
||||
const initWorker = ({
|
||||
id,
|
||||
@@ -37,7 +36,7 @@ const initWorker = ({
|
||||
useBackup: boolean;
|
||||
db: DrizzleCli;
|
||||
}) => {
|
||||
let worker = new Worker(
|
||||
const worker = new Worker(
|
||||
"autumn",
|
||||
async (job: Job) => {
|
||||
const logtail = logger.child({
|
||||
@@ -52,7 +51,7 @@ const initWorker = ({
|
||||
});
|
||||
|
||||
try {
|
||||
if (job.name == JobName.DetectBaseVariant) {
|
||||
if (job.name === JobName.DetectBaseVariant) {
|
||||
await detectBaseVariant({
|
||||
db,
|
||||
curProduct: job.data.curProduct,
|
||||
@@ -61,7 +60,7 @@ const initWorker = ({
|
||||
return;
|
||||
}
|
||||
|
||||
if (job.name == JobName.GenerateFeatureDisplay) {
|
||||
if (job.name === JobName.GenerateFeatureDisplay) {
|
||||
await runSaveFeatureDisplayTask({
|
||||
db,
|
||||
feature: job.data.feature,
|
||||
@@ -70,7 +69,7 @@ const initWorker = ({
|
||||
return;
|
||||
}
|
||||
|
||||
if (job.name == JobName.Migration) {
|
||||
if (job.name === JobName.Migration) {
|
||||
await runMigrationTask({
|
||||
db,
|
||||
payload: job.data,
|
||||
@@ -89,6 +88,14 @@ const initWorker = ({
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (job.name === JobName.RewardMigration) {
|
||||
await runRewardMigrationTask({
|
||||
db,
|
||||
payload: job.data,
|
||||
logger: logtail,
|
||||
});
|
||||
}
|
||||
} catch (error: any) {
|
||||
logtail.error(`Failed to process bullmq job: ${job.name}`, {
|
||||
jobName: job.name,
|
||||
@@ -100,8 +107,8 @@ const initWorker = ({
|
||||
}
|
||||
|
||||
// TRIGGER CHECKOUT REWARD
|
||||
if (job.name == JobName.TriggerCheckoutReward) {
|
||||
let lockKey = `reward_trigger:${job.data.customer?.internal_id}`;
|
||||
if (job.name === JobName.TriggerCheckoutReward) {
|
||||
const lockKey = `reward_trigger:${job.data.customer?.internal_id}`;
|
||||
if (
|
||||
!(await acquireLock({
|
||||
lockKey,
|
||||
@@ -198,7 +205,7 @@ const initWorker = ({
|
||||
}
|
||||
});
|
||||
|
||||
worker.on("failed", (job, error) => {
|
||||
worker.on("failed", (_, error) => {
|
||||
console.log("WORKER FAILED:", error.message);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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'\
|
||||
|
||||
|
||||
|
||||
255
server/tests/advanced/referrals/paid/referrals13.ts
Normal file
255
server/tests/advanced/referrals/paid/referrals13.ts
Normal 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` : ""}`
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
264
server/tests/advanced/referrals/paid/referrals14.ts
Normal file
264
server/tests/advanced/referrals/paid/referrals14.ts
Normal 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` : ""}`
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
296
server/tests/advanced/referrals/paid/referrals15.ts
Normal file
296
server/tests/advanced/referrals/paid/referrals15.ts
Normal 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` : ""}`
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
351
server/tests/advanced/referrals/paid/referrals16.ts
Normal file
351
server/tests/advanced/referrals/paid/referrals16.ts
Normal 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` : ""}`
|
||||
// );
|
||||
// });
|
||||
// });
|
||||
// });
|
||||
// });
|
||||
@@ -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)"
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
// }
|
||||
// }
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
220
shared/index.ts
220
shared/index.ts
@@ -4,153 +4,129 @@ export { schemas };
|
||||
|
||||
// Auth Models
|
||||
export * from "./db/auth-schema.js";
|
||||
export * from "./enums/APIVersion.js";
|
||||
export * from "./enums/AttachErrCode.js";
|
||||
export * from "./enums/ErrCode.js";
|
||||
export * from "./enums/LoggerAction.js";
|
||||
// ENUMS
|
||||
export * from "./enums/SuccessCode.js";
|
||||
export * from "./enums/WebhookEventType.js";
|
||||
// ANALYTICS MODELS
|
||||
export * from "./models/analyticsModels/actionEnums.js";
|
||||
export * from "./models/analyticsModels/actionTable.js";
|
||||
export * from "./models/attachModels/attachBody.js";
|
||||
export * from "./models/attachModels/attachEnums/AttachBranch.js";
|
||||
export * from "./models/attachModels/attachEnums/AttachConfig.js";
|
||||
export * from "./models/attachModels/attachEnums/AttachFunction.js";
|
||||
// Attach Models
|
||||
export * from "./models/attachModels/attachPreviewModels.js";
|
||||
export * from "./models/attachModels/checkoutModels.js";
|
||||
export * from "./models/authModels/membership.js";
|
||||
|
||||
// Gen Models
|
||||
export * from "./models/genModels/genEnums.js";
|
||||
|
||||
// 1. Org Models
|
||||
export * from "./models/orgModels/orgTable.js";
|
||||
export * from "./models/orgModels/orgConfig.js";
|
||||
export * from "./models/orgModels/frontendOrg.js";
|
||||
|
||||
// 2. Feature Models
|
||||
export * from "./models/featureModels/featureTable.js";
|
||||
export * from "./models/chatResultModels/chatResultFeature.js";
|
||||
export * from "./models/chatResultModels/chatResultFeature.js";
|
||||
// 4. Chat Result Models
|
||||
export * from "./models/chatResultModels/chatResultTable.js";
|
||||
export * from "./models/checkModels/checkPreviewModels.js";
|
||||
export * from "./models/cusModels/cusExpand.js";
|
||||
// 8. Customer Models
|
||||
export * from "./models/cusModels/cusModels.js";
|
||||
export * from "./models/cusModels/cusResModels/cusFeatureResponse.js";
|
||||
export * from "./models/cusModels/cusResModels/cusProductResponse.js";
|
||||
export * from "./models/cusModels/cusResModels/cusReferralsResponse.js";
|
||||
// Cus response
|
||||
export * from "./models/cusModels/cusResponseModels.js";
|
||||
export * from "./models/cusModels/cusTable.js";
|
||||
export * from "./models/cusModels/entityModels/entityExpand.js";
|
||||
export * from "./models/cusModels/entityModels/entityModels.js";
|
||||
export * from "./models/cusModels/entityModels/entityResModels.js";
|
||||
export * from "./models/cusModels/entityModels/entityTable.js";
|
||||
export * from "./models/cusModels/fullCusModel.js";
|
||||
export * from "./models/cusModels/invoiceModels/invoiceModels.js";
|
||||
export * from "./models/cusModels/invoiceModels/invoiceResponseModels.js";
|
||||
export * from "./models/cusModels/invoiceModels/invoiceTable.js";
|
||||
export * from "./models/cusProductModels/cusEntModels/cusEntModels.js";
|
||||
export * from "./models/cusProductModels/cusEntModels/cusEntTable.js";
|
||||
export * from "./models/cusProductModels/cusEntModels/cusEntWithProduct.js";
|
||||
export * from "./models/cusProductModels/cusEntModels/replaceableSchema.js";
|
||||
export * from "./models/cusProductModels/cusEntModels/replaceableTable.js";
|
||||
export * from "./models/cusProductModels/cusEntModels/resetCusEnt.js";
|
||||
export * from "./models/cusProductModels/cusEntModels/rolloverModels/rolloverTable.js";
|
||||
export * from "./models/cusProductModels/cusPriceModels/cusPriceModels.js";
|
||||
export * from "./models/cusProductModels/cusPriceModels/cusPriceTable.js";
|
||||
export * from "./models/cusProductModels/cusProductEnums.js";
|
||||
// 7. Cus Product Models
|
||||
export * from "./models/cusProductModels/cusProductModels.js";
|
||||
export * from "./models/cusProductModels/cusProductTable.js";
|
||||
export * from "./models/devModels/apiKeyModels.js";
|
||||
export * from "./models/devModels/apiKeyTable.js";
|
||||
// 5. Others: events, apiKeys
|
||||
export * from "./models/eventModels/eventModels.js";
|
||||
export * from "./models/eventModels/eventTable.js";
|
||||
export * from "./models/featureModels/featureConfig/creditConfig.js";
|
||||
export * from "./models/featureModels/featureConfig/meteredConfig.js";
|
||||
export * from "./models/featureModels/featureEnums.js";
|
||||
export * from "./models/featureModels/featureModels.js";
|
||||
export * from "./models/featureModels/featureResModels.js";
|
||||
export * from "./models/featureModels/featureConfig/meteredConfig.js";
|
||||
export * from "./models/featureModels/featureConfig/creditConfig.js";
|
||||
|
||||
// 2. Feature Models
|
||||
export * from "./models/featureModels/featureTable.js";
|
||||
// Gen Models
|
||||
export * from "./models/genModels/genEnums.js";
|
||||
export * from "./models/migrationModels/migrationErrorTable.js";
|
||||
export * from "./models/migrationModels/migrationJobTable.js";
|
||||
export * from "./models/migrationModels/migrationModels.js";
|
||||
export * from "./models/orgModels/frontendOrg.js";
|
||||
export * from "./models/orgModels/orgConfig.js";
|
||||
// 1. Org Models
|
||||
export * from "./models/orgModels/orgTable.js";
|
||||
export * from "./models/otherModels/metadataModels.js";
|
||||
export * from "./models/otherModels/metadataTable.js";
|
||||
export * from "./models/productModels/entModels/entEnums.js";
|
||||
export * from "./models/productModels/entModels/entModels.js";
|
||||
// 3. Entitlement Models
|
||||
export * from "./models/productModels/entModels/entTable.js";
|
||||
export * from "./models/productModels/entModels/entModels.js";
|
||||
export * from "./models/productModels/entModels/entEnums.js";
|
||||
|
||||
// 4. Free Trial Models
|
||||
export * from "./models/productModels/freeTrialModels/freeTrialEnums.js";
|
||||
export * from "./models/productModels/freeTrialModels/freeTrialModels.js";
|
||||
export * from "./models/productModels/freeTrialModels/freeTrialTable.js";
|
||||
|
||||
// 4. Price Models
|
||||
export * from "./models/productModels/priceModels/priceEnums.js";
|
||||
export * from "./models/productModels/priceModels/priceConfig/fixedPriceConfig.js";
|
||||
export * from "./models/productModels/priceModels/priceConfig/usagePriceConfig.js";
|
||||
export * from "./models/productModels/priceModels/priceTable.js";
|
||||
// 4. Price Models
|
||||
export * from "./models/productModels/priceModels/priceEnums.js";
|
||||
export * from "./models/productModels/priceModels/priceModels.js";
|
||||
|
||||
export * from "./models/productModels/priceModels/priceTable.js";
|
||||
// 5. Product Models
|
||||
export * from "./models/productModels/productEnums.js";
|
||||
export * from "./models/productModels/productTable.js";
|
||||
export * from "./models/productModels/productModels.js";
|
||||
export * from "./models/productModels/productRelations.js";
|
||||
|
||||
// 6. Product V2 Models
|
||||
export * from "./models/productV2Models/productV2Models.js";
|
||||
export * from "./models/productV2Models/productResponseModels.js";
|
||||
export * from "./models/productV2Models/productItemModels/productItemModels.js";
|
||||
export * from "./models/productV2Models/productItemModels/prodItemResponseModels.js";
|
||||
export * from "./models/productModels/productTable.js";
|
||||
export * from "./models/productV2Models/productItemModels/featureItem.js";
|
||||
export * from "./models/productV2Models/productItemModels/featurePriceItem.js";
|
||||
export * from "./models/productV2Models/productItemModels/priceItem.js";
|
||||
export * from "./models/productV2Models/productItemModels/prodItemResponseModels.js";
|
||||
export * from "./models/productV2Models/productItemModels/productItemEnums.js";
|
||||
|
||||
// 7. Cus Product Models
|
||||
export * from "./models/cusProductModels/cusProductModels.js";
|
||||
export * from "./models/cusProductModels/cusProductTable.js";
|
||||
export * from "./models/cusProductModels/cusProductEnums.js";
|
||||
export * from "./models/cusProductModels/cusPriceModels/cusPriceModels.js";
|
||||
export * from "./models/cusProductModels/cusPriceModels/cusPriceTable.js";
|
||||
|
||||
export * from "./models/cusProductModels/cusEntModels/cusEntModels.js";
|
||||
export * from "./models/cusProductModels/cusEntModels/cusEntWithProduct.js";
|
||||
export * from "./models/cusProductModels/cusEntModels/cusEntTable.js";
|
||||
export * from "./models/cusProductModels/cusEntModels/replaceableTable.js";
|
||||
export * from "./models/cusProductModels/cusEntModels/replaceableSchema.js";
|
||||
export * from "./models/cusProductModels/cusEntModels/rolloverModels/rolloverTable.js";
|
||||
export * from "./models/cusProductModels/cusEntModels/resetCusEnt.js";
|
||||
|
||||
// 8. Customer Models
|
||||
export * from "./models/cusModels/cusModels.js";
|
||||
export * from "./models/cusModels/cusTable.js";
|
||||
export * from "./models/cusModels/fullCusModel.js";
|
||||
export * from "./models/cusModels/cusExpand.js";
|
||||
export * from "./models/cusModels/invoiceModels/invoiceResponseModels.js";
|
||||
export * from "./models/cusModels/invoiceModels/invoiceTable.js";
|
||||
// Cus response
|
||||
export * from "./models/cusModels/cusResponseModels.js";
|
||||
export * from "./models/cusModels/cusResModels/cusProductResponse.js";
|
||||
export * from "./models/cusModels/cusResModels/cusFeatureResponse.js";
|
||||
export * from "./models/cusModels/cusResModels/cusReferralsResponse.js";
|
||||
|
||||
export * from "./models/cusModels/entityModels/entityModels.js";
|
||||
export * from "./models/cusModels/entityModels/entityTable.js";
|
||||
export * from "./models/cusModels/entityModels/entityExpand.js";
|
||||
export * from "./models/cusModels/entityModels/entityResModels.js";
|
||||
|
||||
// 4. Chat Result Models
|
||||
export * from "./models/chatResultModels/chatResultTable.js";
|
||||
export * from "./models/chatResultModels/chatResultFeature.js";
|
||||
|
||||
// Reward Models
|
||||
export * from "./models/rewardModels/rewardModels/rewardModels.js";
|
||||
export * from "./models/rewardModels/rewardModels/rewardEnums.js";
|
||||
export * from "./models/rewardModels/rewardModels/rewardTable.js";
|
||||
export * from "./models/rewardModels/rewardModels/rewardResponseModels.js";
|
||||
|
||||
export * from "./models/rewardModels/rewardProgramModels/rewardProgramModels.js";
|
||||
export * from "./models/rewardModels/rewardProgramModels/rewardProgramEnums.js";
|
||||
export * from "./models/rewardModels/rewardProgramModels/rewardProgramTable.js";
|
||||
export * from "./models/productV2Models/productItemModels/productItemModels.js";
|
||||
export * from "./models/productV2Models/productResponseModels.js";
|
||||
// 6. Product V2 Models
|
||||
export * from "./models/productV2Models/productV2Models.js";
|
||||
export * from "./models/rewardModels/referralModels/referralCodeTable.js";
|
||||
export * from "./models/rewardModels/referralModels/referralModels.js";
|
||||
export * from "./models/rewardModels/referralModels/rewardRedemptionTable.js";
|
||||
export * from "./models/rewardModels/referralModels/referralCodeTable.js";
|
||||
|
||||
// 5. Others: events, apiKeys
|
||||
export * from "./models/eventModels/eventModels.js";
|
||||
export * from "./models/eventModels/eventTable.js";
|
||||
|
||||
export * from "./models/devModels/apiKeyModels.js";
|
||||
export * from "./models/devModels/apiKeyTable.js";
|
||||
|
||||
export * from "./models/otherModels/metadataModels.js";
|
||||
export * from "./models/otherModels/metadataTable.js";
|
||||
|
||||
export * from "./models/rewardModels/rewardModels/rewardEnums.js";
|
||||
// Reward Models
|
||||
export * from "./models/rewardModels/rewardModels/rewardModels.js";
|
||||
export * from "./models/rewardModels/rewardModels/rewardResponseModels.js";
|
||||
export * from "./models/rewardModels/rewardModels/rewardTable.js";
|
||||
export * from "./models/rewardModels/rewardProgramModels/rewardProgramEnums.js";
|
||||
export * from "./models/rewardModels/rewardProgramModels/rewardProgramModels.js";
|
||||
export * from "./models/rewardModels/rewardProgramModels/rewardProgramTable.js";
|
||||
export * from "./models/subModels/subModels.js";
|
||||
export * from "./models/subModels/subTable.js";
|
||||
|
||||
export * from "./models/cusModels/invoiceModels/invoiceModels.js";
|
||||
|
||||
export * from "./models/migrationModels/migrationModels.js";
|
||||
export * from "./models/migrationModels/migrationJobTable.js";
|
||||
export * from "./models/migrationModels/migrationErrorTable.js";
|
||||
|
||||
// ANALYTICS MODELS
|
||||
export * from "./models/analyticsModels/actionEnums.js";
|
||||
export * from "./models/analyticsModels/actionTable.js";
|
||||
|
||||
// Attach Models
|
||||
export * from "./models/attachModels/attachPreviewModels.js";
|
||||
export * from "./models/attachModels/attachEnums/AttachBranch.js";
|
||||
export * from "./models/attachModels/attachEnums/AttachFunction.js";
|
||||
export * from "./models/attachModels/attachEnums/AttachConfig.js";
|
||||
export * from "./models/attachModels/checkoutModels.js";
|
||||
export * from "./models/attachModels/attachBody.js";
|
||||
|
||||
// Utils
|
||||
export * from "./utils/displayUtils.js";
|
||||
export * from "./models/checkModels/checkPreviewModels.js";
|
||||
export * from "./models/chatResultModels/chatResultFeature.js";
|
||||
export * from "./utils/productDisplayUtils/getProductItemRes.js";
|
||||
export * from "./utils/productUtils.js";
|
||||
export * from "./utils/productDisplayUtils/sortProductItems.js";
|
||||
export * from "./utils/intervalUtils.js";
|
||||
export * from "./utils/productUtils/priceToInvoiceAmount.js";
|
||||
export * from "./utils/index.js";
|
||||
|
||||
// ENUMS
|
||||
export * from "./enums/SuccessCode.js";
|
||||
export * from "./enums/ErrCode.js";
|
||||
export * from "./enums/LoggerAction.js";
|
||||
export * from "./enums/AttachErrCode.js";
|
||||
export * from "./enums/APIVersion.js";
|
||||
export * from "./enums/WebhookEventType.js";
|
||||
export * from "./utils/intervalUtils.js";
|
||||
export * from "./utils/productDisplayUtils/getProductItemRes.js";
|
||||
export * from "./utils/productDisplayUtils/sortProductItems.js";
|
||||
export * from "./utils/productUtils/priceToInvoiceAmount.js";
|
||||
export * from "./utils/productUtils.js";
|
||||
export * from "./utils/rewardUtils/rewardMigrationUtils.js";
|
||||
@@ -18,4 +18,5 @@ export interface AttachConfig {
|
||||
sameIntervals: boolean;
|
||||
carryTrial: boolean;
|
||||
finalizeInvoice: boolean;
|
||||
requirePaymentMethod: boolean;
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ export const UsageTierSchema = z.object({
|
||||
amount: z.number(),
|
||||
});
|
||||
|
||||
export type UsageTier = z.infer<typeof UsageTierSchema>;
|
||||
|
||||
export const UsagePriceConfigSchema = z.object({
|
||||
type: z.string(),
|
||||
bill_when: z.nativeEnum(BillWhen),
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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"),
|
||||
],
|
||||
]
|
||||
);
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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",
|
||||
|
||||
12
shared/utils/cusProductUtils/formatCusProductUtils.ts
Normal file
12
shared/utils/cusProductUtils/formatCusProductUtils.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { FullCusProduct } from "../../models/cusProductModels/cusProductModels.js";
|
||||
|
||||
export const logCusProducts = ({
|
||||
cusProducts,
|
||||
}: {
|
||||
cusProducts: FullCusProduct[];
|
||||
}) => {
|
||||
console.log(`CUS PRODUCTS:`);
|
||||
for (const cusProduct of cusProducts) {
|
||||
console.log(`${cusProduct.product.id} - ${cusProduct.status}`);
|
||||
}
|
||||
};
|
||||
@@ -7,6 +7,7 @@ export * from "./cusProductUtils/convertCusProduct.js";
|
||||
export * from "./cusProductUtils/productIdToCusProduct.js";
|
||||
export * from "./cusProductUtils/cusProductConstants.js";
|
||||
export * from "./cusProductUtils/cusProductUtils.js";
|
||||
export * from "./cusProductUtils/formatCusProductUtils.js";
|
||||
export * from "./utils.js";
|
||||
|
||||
// Cus ent utils
|
||||
@@ -19,3 +20,4 @@ export * from "./productV2Utils/mapToProductV2.js";
|
||||
|
||||
// Product utils
|
||||
export * from "./productUtils/convertUtils.js";
|
||||
export * from "./productUtils/priceUtils.js";
|
||||
|
||||
177
shared/utils/rewardUtils/rewardMigrationUtils.ts
Normal file
177
shared/utils/rewardUtils/rewardMigrationUtils.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
import type {
|
||||
FixedPriceConfig,
|
||||
Price,
|
||||
Reward,
|
||||
RewardType,
|
||||
UsagePriceConfig,
|
||||
} from "../../index.js";
|
||||
import type { UsageTier } from "../../models/productModels/priceModels/priceConfig/usagePriceConfig.js";
|
||||
import { isFixedPrice, isUsagePrice } from "../productUtils/priceUtils.js";
|
||||
|
||||
// Helper function to check if tier structures match
|
||||
const tiersMatch = (oldTiers: UsageTier[], newTiers: UsageTier[]): boolean => {
|
||||
if (oldTiers.length !== newTiers.length) return false;
|
||||
|
||||
return oldTiers.every((oldTier, index) => {
|
||||
const newTier = newTiers[index];
|
||||
return oldTier.to === newTier.to && oldTier.amount === newTier.amount;
|
||||
});
|
||||
};
|
||||
|
||||
// Match fixed prices by amount
|
||||
const findMatchingFixedPrice = (
|
||||
oldPrice: Price,
|
||||
candidates: Price[],
|
||||
): Price | null => {
|
||||
const oldConfig = oldPrice.config as FixedPriceConfig;
|
||||
|
||||
return (
|
||||
candidates.find((candidate) => {
|
||||
const newConfig = candidate.config as FixedPriceConfig;
|
||||
return newConfig.amount === oldConfig.amount;
|
||||
}) || null
|
||||
);
|
||||
};
|
||||
|
||||
// Match usage prices by feature and billing characteristics
|
||||
const findMatchingUsagePrice = (
|
||||
oldPrice: Price,
|
||||
candidates: Price[],
|
||||
): Price | null => {
|
||||
const oldConfig = oldPrice.config as UsagePriceConfig;
|
||||
|
||||
return (
|
||||
candidates.find((candidate) => {
|
||||
const newConfig = candidate.config as UsagePriceConfig;
|
||||
|
||||
// Match by feature
|
||||
if (newConfig.feature_id !== oldConfig.feature_id) return false;
|
||||
if (newConfig.internal_feature_id !== oldConfig.internal_feature_id)
|
||||
return false;
|
||||
|
||||
// Match by billing behavior
|
||||
if (newConfig.bill_when !== oldConfig.bill_when) return false;
|
||||
if (newConfig.should_prorate !== oldConfig.should_prorate) return false;
|
||||
|
||||
// Optionally match by tier structure
|
||||
if (!tiersMatch(oldConfig.usage_tiers, newConfig.usage_tiers))
|
||||
return false;
|
||||
|
||||
return true;
|
||||
}) || null
|
||||
);
|
||||
};
|
||||
|
||||
// Main matching function with type-specific logic
|
||||
const findBestMatch = (oldPrice: Price, newPrices: Price[]): Price | null => {
|
||||
// First, filter by basic characteristics
|
||||
const candidates = newPrices.filter(
|
||||
(newPrice) =>
|
||||
newPrice.config.type === oldPrice.config.type &&
|
||||
newPrice.config.interval === oldPrice.config.interval &&
|
||||
newPrice.config.interval_count === oldPrice.config.interval_count,
|
||||
);
|
||||
|
||||
if (candidates.length === 0) return null;
|
||||
if (candidates.length === 1) return candidates[0];
|
||||
|
||||
// If multiple candidates, use type-specific matching
|
||||
if (isFixedPrice({ price: oldPrice })) {
|
||||
return findMatchingFixedPrice(oldPrice, candidates);
|
||||
} else if (isUsagePrice({ price: oldPrice })) {
|
||||
return findMatchingUsagePrice(oldPrice, candidates);
|
||||
}
|
||||
|
||||
// Fallback to first candidate
|
||||
return candidates[0];
|
||||
};
|
||||
|
||||
export interface RewardMigrationResult {
|
||||
willMigrateCount: number;
|
||||
willNotMigrateCount: number;
|
||||
}
|
||||
|
||||
export interface RewardPriceAnalysisResult {
|
||||
validPriceCount: number;
|
||||
invalidPriceCount: number;
|
||||
}
|
||||
|
||||
export function analyzeRewardMigration({
|
||||
rewards,
|
||||
oldPrices,
|
||||
newPrices,
|
||||
rewardTypesToCheck,
|
||||
}: {
|
||||
rewards: Reward[];
|
||||
oldPrices: Price[];
|
||||
newPrices: Price[];
|
||||
rewardTypesToCheck: RewardType[];
|
||||
}): RewardMigrationResult {
|
||||
let willMigrateCount = 0;
|
||||
let willNotMigrateCount = 0;
|
||||
|
||||
// Filter rewards to only those we care about and that have discount configs with price_ids
|
||||
const relevantRewards = rewards.filter(
|
||||
(reward) =>
|
||||
rewardTypesToCheck.includes(reward.type) &&
|
||||
reward.discount_config?.price_ids &&
|
||||
reward.discount_config.price_ids.length > 0,
|
||||
);
|
||||
|
||||
for (const reward of relevantRewards) {
|
||||
if (!reward.discount_config?.price_ids) continue;
|
||||
|
||||
for (const priceId of reward.discount_config.price_ids) {
|
||||
const oldPrice = oldPrices.find((p) => p.id === priceId);
|
||||
if (!oldPrice) {
|
||||
// Price not in old prices list, skip
|
||||
continue;
|
||||
}
|
||||
|
||||
const matchingNewPrice = findBestMatch(oldPrice, newPrices);
|
||||
|
||||
if (matchingNewPrice) {
|
||||
willMigrateCount++;
|
||||
} else {
|
||||
willNotMigrateCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
willMigrateCount,
|
||||
willNotMigrateCount,
|
||||
};
|
||||
}
|
||||
|
||||
export function analyzeRewardPrices({
|
||||
reward,
|
||||
availablePriceIds,
|
||||
}: {
|
||||
reward: Reward;
|
||||
availablePriceIds: string[];
|
||||
}): RewardPriceAnalysisResult {
|
||||
let validPriceCount = 0;
|
||||
let invalidPriceCount = 0;
|
||||
|
||||
// Skip rewards that apply to all products
|
||||
if (reward.discount_config?.apply_to_all) {
|
||||
return { validPriceCount: 0, invalidPriceCount: 0 };
|
||||
}
|
||||
|
||||
// Check each price ID in the reward
|
||||
if (reward.discount_config?.price_ids) {
|
||||
for (const priceId of reward.discount_config.price_ids) {
|
||||
if (availablePriceIds.includes(priceId)) {
|
||||
validPriceCount++;
|
||||
} else {
|
||||
invalidPriceCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
validPriceCount,
|
||||
invalidPriceCount,
|
||||
};
|
||||
}
|
||||
@@ -17,7 +17,10 @@
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"recommended": true
|
||||
"recommended": true,
|
||||
"complexity": {
|
||||
"noStaticOnlyClass": "off"
|
||||
}
|
||||
}
|
||||
},
|
||||
"javascript": {
|
||||
|
||||
@@ -162,10 +162,10 @@ const MainContent = () => {
|
||||
"w-full h-full overflow-auto flex justify-center bg-stone-50"
|
||||
)}
|
||||
>
|
||||
<div className="hidden md:flex w-full h-full justify-center">
|
||||
<div className="w-full h-full justify-center">
|
||||
<Outlet />
|
||||
</div>
|
||||
<div className="md:hidden w-full h-full flex items-center justify-center">
|
||||
{/* <div className="md:hidden w-full h-full flex items-center justify-center">
|
||||
<div className="bg-white p-6 rounded-lg shadow-sm text-center">
|
||||
<h2 className="text-xl font-semibold mb-2">
|
||||
Autumn is coming to mobile soon
|
||||
@@ -175,7 +175,7 @@ const MainContent = () => {
|
||||
your desktop?
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div> */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user