Merge branch 'staging' into Fixes/Update-logic-reward-program

This commit is contained in:
John Yeo
2025-09-22 13:17:46 +01:00
118 changed files with 23375 additions and 5398 deletions

2
.gitignore vendored
View File

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

View File

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

15047
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -7,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'

View File

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

View File

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

View File

@@ -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";

View File

@@ -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 (

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 ?? []
// ) ?? [],
// });
// }
}

View File

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

View File

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

View File

@@ -0,0 +1,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,
// },
// });

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,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({

View File

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

View File

@@ -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)
),

View File

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

View File

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

View File

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

View File

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

View File

@@ -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({

View File

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

View File

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

View File

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

View File

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

View File

@@ -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";

View File

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

View File

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

View File

@@ -78,7 +78,6 @@ const handleIdIsNull = async ({
}
// 2. Check if email already exists
let existingCustomers = await CusService.getByEmail({
db,
email: newCus.email,

View File

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

View File

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

View File

@@ -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;
}
}

View File

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

View File

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

View File

@@ -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;
};

View File

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

View File

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

View File

@@ -1,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;
},

View File

@@ -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);
};

View File

@@ -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,
});

View File

@@ -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,

View File

@@ -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,

View File

@@ -1,49 +1,46 @@
import {
AppEnv,
type AppEnv,
BillingInterval,
BillingType,
CreateProduct,
Entitlement,
type CreateProduct,
EntInterval,
type Entitlement,
EntitlementSchema,
ErrCode,
Feature,
FixedPriceConfig,
type Feature,
type FixedPriceConfig,
type FullProduct,
intervalsSame,
Organization,
Price,
type Organization,
type Price,
PriceSchema,
PriceType,
ProcessorType,
Product,
ProductOptions,
type Product,
ProductSchema,
UsagePriceConfig,
type UsagePriceConfig,
} from "@autumn/shared";
import { FullProduct } from "@autumn/shared";
import { Decimal } from "decimal.js";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import { createStripePriceIFNotExist } from "@/external/stripe/createStripePrice/createStripePrice.js";
import { createStripeCli } from "@/external/stripe/utils.js";
import {
getBillingInterval,
getBillingType,
} from "@/internal/products/prices/priceUtils.js";
import { createStripeCli } from "@/external/stripe/utils.js";
import { ProductService } from "./ProductService.js";
import {
import RecaseError from "@/utils/errorUtils.js";
import { generateId, notNullish } from "@/utils/genUtils.js";
import type {
AttachParams,
InsertCusProductParams,
} from "../customers/cusProducts/AttachParams.js";
import {
getEntitlementsForProduct,
getEntsWithFeature,
} from "./entitlements/entitlementUtils.js";
import { Decimal } from "decimal.js";
import { generateId, notNullish } from "@/utils/genUtils.js";
import { PriceService } from "./prices/PriceService.js";
import { EntitlementService } from "./entitlements/EntitlementService.js";
import RecaseError from "@/utils/errorUtils.js";
import { createStripePriceIFNotExist } from "@/external/stripe/createStripePrice/createStripePrice.js";
import { FreeTrialService } from "./free-trials/FreeTrialService.js";
import { DrizzleCli } from "@/db/initDrizzle.js";
import { compareBillingIntervals } from "./prices/priceUtils/priceIntervalUtils.js";
import { isStripeConnected } from "../orgs/orgUtils.js";
import { EntitlementService } from "./entitlements/EntitlementService.js";
import { getEntitlementsForProduct } from "./entitlements/entitlementUtils.js";
import { FreeTrialService } from "./free-trials/FreeTrialService.js";
import { ProductService } from "./ProductService.js";
import { PriceService } from "./prices/PriceService.js";
import { compareBillingIntervals } from "./prices/priceUtils/priceIntervalUtils.js";
import { isDefaultTrialFullProduct } from "./productUtils/classifyProduct.js";
export const getLatestProducts = (products: FullProduct[]) => {
@@ -86,7 +83,7 @@ export const constructProduct = ({
processor?: any;
baseVariantId?: string | null;
}) => {
let newProduct: Product = {
const newProduct: Product = {
...productData,
org_id: orgId,
env,
@@ -129,8 +126,8 @@ export const isProductUpgrade = ({
return true;
}
let billingInterval1 = getBillingInterval(prices1); // pro quarter
let billingInterval2 = getBillingInterval(prices2); // premium
const billingInterval1 = getBillingInterval(prices1); // pro quarter
const billingInterval2 = getBillingInterval(prices2); // premium
// 2. Get total price for each product
const getTotalPrice = (prices: Price[]) => {
@@ -186,7 +183,7 @@ export const isFreeProduct = (prices: Price[]) => {
export const getOptionsFromPrices = (prices: Price[], features: Feature[]) => {
const featureToOptions: { [key: string]: any } = {};
for (const price of prices) {
if (price.config!.type == PriceType.Fixed) {
if (price.config!.type === PriceType.Fixed) {
continue;
}
@@ -231,7 +228,7 @@ export const checkStripeProductExists = async ({
logger: any;
}) => {
let createNew = false;
let stripeCli = createStripeCli({
const stripeCli = createStripeCli({
org,
env,
});
@@ -240,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,

View File

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

View File

@@ -1,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(

View File

@@ -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,
},
});
};

View File

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

View File

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

View File

@@ -1,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);
}
};

View File

@@ -3,6 +3,7 @@ export enum JobName {
UpdateUsage = "update-usage",
Migration = "migration",
RewardMigration = "reward-migration",
TriggerCheckoutReward = "trigger-checkout-reward",
GenerateFeatureDisplay = "generate-feature-display",

View File

@@ -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)`,

View File

@@ -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);
});
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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";

View File

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

View File

@@ -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),

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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}`);
}
};

View File

@@ -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";

View 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,
};
}

View File

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

View File

@@ -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>

View File

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

Some files were not shown because too many files have changed in this diff Show More