Merge branch 'staging' into feat/reward-migrations

This commit is contained in:
John Yeo
2025-09-21 18:24:24 +01:00
158 changed files with 23938 additions and 6042 deletions

3
.gitignore vendored
View File

@@ -105,3 +105,6 @@ supabase/
migration.sh
stat.sh
CLAUDE.md
interview

View File

@@ -33,7 +33,13 @@ bun install
```bash
bun setup
```
4. Run Autumn:
4. Generate the relevant tables in your postgres DB
```bash
bun db:generate && bun db:migrate
```
5. Run Autumn:
For Windows
```bash

View File

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

View File

@@ -21,9 +21,11 @@
"dev:wsl": "concurrently \"cd server && npm run dev\" \"cd vite && npm run dev\" \"cd shared && npm run dev\"",
"setup": "node setup.js",
"setupci": "node setupci",
"db:push": " pnpm -F shared db:push",
"db:generate": "pnpm -F shared db:generate",
"db:migrate": " pnpm -F shared db:migrate",
"db:push": " bun -F @autumn/shared db:push",
"db:generate": "bun -F @autumn/shared db:generate",
"db:migrate": " bun -F @autumn/shared db:migrate",
"docker:up": "docker compose -f docker-compose.dev.yml up --build",
"docker:up:unix": "docker compose -f docker-compose.unix.yml up --build",
"docker:up:ci": "docker compose -f docker-compose.ci.yml up --build",

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

@@ -42,7 +42,8 @@ const skipIds = [
];
orgSlugs = ["supermemory"];
const customerId = "co1VPgUU59q43d5P2rFt4c";
let customerId = null;
// customerId = "co1VPgUU59q43d5P2rFt4c";
const getSingleCustomer = async ({
stripeCli,
@@ -81,11 +82,15 @@ const getSingleCustomer = async ({
// ),
// });
let scheduleIds = customers[0].customer_products.flatMap(
(cp) => cp.scheduled_ids || []
);
scheduleIds = Array.from(new Set(scheduleIds));
const stripeSchedules = await getStripeSchedules({
stripeCli,
scheduleIds: customers[0].customer_products.flatMap(
(cp) => cp.scheduled_ids || []
),
scheduleIds,
});
const entities = await EntityService.list({
@@ -122,12 +127,6 @@ const checkCustomerCorrect = async ({
// console.log(`Checking ${fullCus.email} (${fullCus.id})`);
const cusProducts = fullCus.customer_products;
// await expectSubToBeCorrect({
// db,
// customerId: fullCus.id!,
// org,
// env: AppEnv.Live,
// });
await checkCusSubCorrect({
db,
fullCus,

67
server/src/check2.ts Normal file
View File

@@ -0,0 +1,67 @@
import { config } from "dotenv";
config();
import {
getAllEntities,
getAllFullCustomers,
} from "@/utils/scriptUtils/getAll/getAllAutumnCustomers.js";
import { initDrizzle } from "@/db/initDrizzle.js";
import {
AppEnv,
CusProductStatus,
FullCusProduct,
FullCustomer,
Organization,
Entity,
} from "@autumn/shared";
import Stripe from "stripe";
import assert from "assert";
import { cusProductToPrices } from "@autumn/shared";
import { notNullish } from "@/utils/genUtils.js";
import {
getAllStripeSchedules,
getAllStripeSubscriptions,
} from "@/utils/scriptUtils/getAll/getAllStripeSubs.js";
import { OrgService } from "@/internal/orgs/OrgService.js";
import { createStripeCli } from "@/external/stripe/utils.js";
import { CusService } from "@/internal/customers/CusService.js";
import { getStripeSchedules } from "@/external/stripe/stripeSubUtils.js";
import { createSupabaseClient } from "@/external/supabaseUtils.js";
import { isFreeProduct, isOneOff } from "@/internal/products/productUtils.js";
import { getRelatedCusPrice } from "./internal/customers/cusProducts/cusEnts/cusEntUtils.js";
import { checkCusSubCorrect } from "./utils/checkUtils/checkCustomerCorrect.js";
import { EntityService } from "./internal/api/entities/EntityService.js";
const { db } = initDrizzle({ maxConnections: 5 });
export const check = async () => {
const env = AppEnv.Live;
const orgId = "99XYziU2vChNNpdeEpvse09b8UF6BPME";
let fullCustomers = await getAllFullCustomers({
db,
orgId,
env,
});
const checkCustomers = ["9bafd636-0c52-46b3-8ecd-1708d6faa373"];
fullCustomers = fullCustomers.filter((customer) =>
checkCustomers.includes(customer.id || "")
);
for (const customer of fullCustomers) {
console.log(`Checking ${customer.name} (${customer.id})`);
const cusProducts = customer.customer_products;
const cusEnts = cusProducts.flatMap((cp) => cp.customer_entitlements);
}
};
check()
.catch((error) => {
console.error(error);
process.exit(1);
})
.finally(() => {
process.exit(0);
});

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

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

@@ -20,11 +20,12 @@ export const priceToOneOffAndTiered = ({
}) => {
const config = price.config as UsagePriceConfig;
let quantity = options?.quantity!;
let overage = quantity * config.billing_units! - relatedEnt.allowance!;
let overage = new Decimal(quantity).mul(config.billing_units!).toNumber();
// let overage = quantity * config.billing_units! - relatedEnt.allowance!;
if (overage <= 0) {
return null;
}
// if (overage <= 0) {
// return null;
// }
const amount = getPriceForOverage(price, overage);
if (!config.stripe_product_id) {

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

@@ -216,7 +216,9 @@ export const getStripeSchedules = async ({
const batchPricesGet = [];
for (const item of schedule.phases[0].items) {
batchPricesGet.push(stripeCli.prices.retrieve(item.price as string));
batchPricesGet.push(
stripeCli.prices.retrieve((item.price as Stripe.Price).id as string)
);
}
const prices = await Promise.all(batchPricesGet);
const interval = prices[0].recurring?.interval;

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,
@@ -187,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: {
@@ -224,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,5 +1,6 @@
import { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js";
import {
formatPrice,
getBillingType,
getPriceEntitlement,
priceIsOneOffAndTiered,
@@ -32,9 +33,7 @@ export const getOptionsFromCheckoutSession = async ({
for (const price of prices) {
let config = price.config as UsagePriceConfig;
if (getBillingType(config) != BillingType.UsageInAdvance) {
continue;
}
if (getBillingType(config) != BillingType.UsageInAdvance) continue;
const lineItem = findStripeItemForPrice({
price,
@@ -47,7 +46,8 @@ export const getOptionsFromCheckoutSession = async ({
let relatedEnt = getPriceEntitlement(price, ents);
if (priceIsOneOffAndTiered(price, relatedEnt)) {
quantity = (lineItem.quantity || 0) + (relatedEnt.allowance || 0);
// quantity = lineItem.quantity || 0;
continue;
} else {
quantity = lineItem.quantity || 0;
}

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

@@ -130,9 +130,6 @@ const handleInArrearProrated = async ({
}
}
console.log("New entities: ", newEntities);
console.log("Cus ent ID: ", linkedCusEnt.id);
let updated = await CusEntService.update({
db,
id: linkedCusEnt.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

@@ -121,11 +121,10 @@ export const handleSchedulePhaseCompleted = async ({
scheduled_ids: [],
},
});
} catch (error) {
} catch (error: any) {
logger.error(
`schedule.phase.completed: failed to cancel schedule ${schedule.id}`
`schedule.phase.completed: failed to cancel schedule ${schedule.id}, error: ${error.message}`
);
logger.error({ error });
}
}
// const currentPhase = schedule.phases.find(

View File

@@ -102,9 +102,7 @@ export const handleSubCanceled = async ({
const { db, env, logtail: logger } = req;
if (!canceledFromPortal || updatedCusProducts.length == 0) {
return;
}
if (!canceledFromPortal || updatedCusProducts.length == 0) return;
await updateCusProductCanceled({
db,
@@ -194,6 +192,8 @@ export const handleSubCanceled = async ({
}
for (let cusProd of updatedCusProducts) {
console.log("Sending webhook for canceled product: ", cusProd.product.id);
try {
await addProductsUpdatedWebhookTask({
req,

View File

@@ -7,6 +7,7 @@ import { AttachScenario, FullCusProduct } from "@autumn/shared";
import Stripe from "stripe";
import { isMultiProductSub } from "@/internal/customers/attach/mergeUtils/mergeUtils.js";
import { DrizzleCli } from "@/db/initDrizzle.js";
import { getSubScenarioFromCache } from "@/internal/customers/cusCache/subCacheUtils.js";
const isSubRenewed = ({
previousAttributes,
sub,
@@ -70,12 +71,18 @@ export const handleSubRenewed = async ({
if (!renewed || updatedCusProducts.length == 0) return;
const subScenario = await getSubScenarioFromCache({ subId: sub.id });
console.log(`Renewed: ${renewed}, subScenario: ${subScenario}`);
if (subScenario === AttachScenario.Renew) return;
const customer = updatedCusProducts[0].customer;
let cusProducts = await CusProductService.list({
db,
internalCustomerId: customer!.internal_id,
});
console.log(`handling sub.renewed!`);
if (isMultiProductSub({ sub, cusProducts }) || sub.schedule) return;
await CusProductService.updateByStripeSubId({

View File

@@ -2,9 +2,9 @@ import {
ActionType,
AppEnv,
AuthType,
CusProductStatus,
FullCusProduct,
FullProduct,
notNullish,
Organization,
} from "@autumn/shared";
@@ -23,7 +23,8 @@ import { ActionService } from "@/internal/analytics/ActionService.js";
import { constructAction } from "@/internal/analytics/actionUtils.js";
import { parseReqForAction } from "@/internal/analytics/actionUtils.js";
import { RELEVANT_STATUSES } from "@/internal/customers/cusProducts/CusProductService.js";
import { cusProductToPrices, cusProductToProduct } from "@autumn/shared";
import { cusProductToProduct } from "@autumn/shared";
import { getSingleEntityResponse } from "@/internal/api/entities/getEntityUtils.js";
interface ActionDetails {
request_id: string;
@@ -121,19 +122,8 @@ export const handleProductsUpdated = async ({
// Product:
let product = cusProduct.product;
// const prices = cusProductToPrices({ cusProduct });
// const ents = cusProductToEnts({ cusProduct });
// let freeTrial = cusProduct.free_trial;
let fullProduct: FullProduct = cusProductToProduct({ cusProduct });
// {
// ...product,
// prices,
// entitlements: ents,
// free_trial: freeTrial || null,
// };
let customer = await CusService.getFull({
db,
idOrInternalId: data.customerId || data.internalCustomerId,
@@ -165,9 +155,6 @@ export const handleProductsUpdated = async ({
features,
});
// 1. Log action to DB
// console.log(`handling products.updated for customer ${customer.id}`);
try {
if (req) {
let action = constructAction({
@@ -205,6 +192,28 @@ export const handleProductsUpdated = async ({
}
}
let entityRes = null;
if (notNullish(customer?.entity)) {
entityRes = await getSingleEntityResponse({
entityId: customer.entity!.id,
org,
env,
fullCus: customer,
entity: customer.entity!,
features,
});
}
// console.log(`Sending svix event for customer ${customer.id}`);
// console.log(
// "Products:",
// cusDetails.products.map((p) => ({
// id: p.id,
// status: p.status,
// quantity: p.quantity,
// }))
// );
// 2. Send Svix event
await sendSvixEvent({
org,
@@ -213,6 +222,7 @@ export const handleProductsUpdated = async ({
data: {
scenario,
customer: cusDetails,
entity: entityRes,
updated_product: productRes,
},
});

View File

@@ -43,11 +43,11 @@ export const runActionHandlerTask = async ({
});
break;
}
} catch (error) {
} catch (error: any) {
logger.error("Error processing action handler job:", {
jobName: job.name,
error,
payload,
// jobName: job.name,
// payload,
message: error.message,
});
} finally {
await releaseLock({ lockKey, useBackup });

View File

@@ -1,10 +1,5 @@
import type { DrizzleCli } from "@/db/initDrizzle.js";
import { getCusWithCache } from "@/internal/customers/cusCache/getCusWithCache.js";
import {
ACTIVE_STATUSES,
RELEVANT_STATUSES,
} from "@/internal/customers/cusProducts/CusProductService.js";
import { CusService } from "@/internal/customers/CusService.js";
import { getCusFeaturesResponse } from "@/internal/customers/cusUtils/cusFeatureResponseUtils/getCusFeaturesResponse.js";
import { processFullCusProducts } from "@/internal/customers/cusUtils/cusProductResponseUtils/processFullCusProducts.js";
@@ -13,7 +8,6 @@ import { nullish } from "@/utils/genUtils.js";
import {
type AppEnv,
Feature,
CusProductStatus,
type Entity,
EntityExpand,
type EntityResponse,
@@ -32,6 +26,7 @@ export const getSingleEntityResponse = async ({
org,
env,
fullCus,
entity,
features,
withAutumnId = false,
}: {
@@ -39,13 +34,10 @@ export const getSingleEntityResponse = async ({
org: Organization;
env: AppEnv;
fullCus: FullCustomer;
entity: Entity;
features: Feature[];
withAutumnId?: boolean;
}) => {
let entity = fullCus.entities.find(
(e: Entity) => e.id == entityId || e.internal_id == entityId
);
const apiVersion = APIVersion.v1_2;
if (!entity) {
@@ -80,7 +72,7 @@ export const getSingleEntityResponse = async ({
let { main, addOns } = await processFullCusProducts({
fullCusProducts: entityCusProducts,
entities: fullCus.entities,
entity,
subs: entitySubs,
org,
apiVersion: APIVersion.v1_2,
@@ -136,7 +128,7 @@ export const getEntityResponse = async ({
logger: any;
skipCache?: boolean;
}) => {
let customer = await getCusWithCache({
let fullCus = await getCusWithCache({
db,
idOrInternalId: customerId,
org,
@@ -147,7 +139,7 @@ export const getEntityResponse = async ({
skipCache,
});
if (!customer) {
if (!fullCus) {
throw new RecaseError({
message: `Customer ${customerId} not found`,
code: ErrCode.CustomerNotFound,
@@ -156,12 +148,26 @@ export const getEntityResponse = async ({
}
const entityResponses: EntityResponse[] = [];
for (const entityId of entityIds) {
const entity = fullCus.entities.find(
(e: Entity) => e.id == entityId || e.internal_id == entityId
);
if (!entity) {
throw new RecaseError({
message: `Entity ${entityId} not found for customer ${fullCus.id}`,
code: ErrCode.EntityNotFound,
statusCode: 400,
});
}
let entityResponse = await getSingleEntityResponse({
entityId,
org,
env,
fullCus: customer,
fullCus,
entity,
features,
withAutumnId,
});
@@ -171,8 +177,8 @@ export const getEntityResponse = async ({
return {
entities: entityResponses,
customer,
fullEntities: customer.entities,
invoices: customer.invoices,
customer: fullCus,
fullEntities: fullCus.entities,
invoices: fullCus.invoices,
};
};

View File

@@ -172,99 +172,6 @@ export const handleDeleteEntity = async (req: any, res: any) => {
return res.status(200).json({
success: true,
});
// const linkedCusEnts = findLinkedCusEnts({
// cusEnts: cusEnt.customer_product.customer_entitlements,
// feature: cusEnt.entitlement.feature,
// });
// if (!cusPriceExists || org.config.prorate_unused) {
// let cusEnts = cusProductsToCusEnts({ cusProducts });
// for (const cusEnt of cusEnts) {
// let relatedCusPrice = getRelatedCusPrice(
// cusEnt,
// cusProducts.flatMap((p: any) => p.customer_prices),
// );
// await removeEntityFromCusEnt({
// db,
// cusEnt,
// entity,
// logger,
// cusPrice: relatedCusPrice,
// customer,
// org,
// env,
// });
// }
// try {
// let stripeCli = createStripeCli({ org, env });
// let curSubs = await getStripeSubs({
// stripeCli,
// subIds: cusProducts.flatMap((p: any) => p.subscription_ids),
// });
// for (const cusProduct of cusProducts) {
// if (cusProduct.internal_entity_id !== entity.internal_id) {
// continue;
// }
// if (cusProduct.status == CusProductStatus.Scheduled) {
// await removeScheduledProduct({
// req,
// db,
// cusProduct,
// cusProducts,
// org,
// env,
// logger,
// renewCurProduct: false,
// });
// } else {
// await cancelCurSubs({
// curCusProduct: cusProduct,
// curSubs,
// stripeCli,
// });
// }
// }
// } catch (error) {
// logger.error("FAILED TO CANCEL SUBS FOR DELETED ENTITY", error);
// }
// // Perform deduction on cus ent
// let updateCusEnt = cusEnts.find(
// (e: any) => e.entitlement.feature.id === entity.feature_id,
// );
// if (updateCusEnt) {
// await CusEntService.increment({
// db,
// id: updateCusEnt.id,
// amount: 1,
// });
// }
// await EntityService.deleteInInternalIds({
// db,
// internalIds: [entity.internal_id],
// orgId: req.orgId,
// env: req.env,
// });
// } else {
// await EntityService.update({
// db,
// internalId: entity.internal_id,
// update: {
// deleted: true,
// },
// });
// }
// logger.info(` ✅ Finished deleting entity ${entity_id}`);
// res.status(200).json({
// success: true,
// });
} catch (error) {
handleRequestError({ error, req, res, action: "delete entity" });
}

View File

@@ -134,7 +134,9 @@ export const getV1CheckResponse = ({
balances.push(newBalance);
// allowed = allowed && actual! >= required;
allowed = actual! + (totalPaidAllowance || 0) >= required;
allowed =
(required && required < 0) ||
actual! + (totalPaidAllowance || 0) >= required;
if (allowed) {
break;

View File

@@ -115,7 +115,7 @@ export const getV2CheckResponse = async ({
if (
(cusFeature && unlimited) ||
usageAllowed ||
// cusFeature.balance >= (requiredBalance || 1)
(requiredBalance && requiredBalance < 0) ||
cusFeature.balance + totalPaidUsageAllowance >= (requiredBalance || 1)
) {
allowed = true;

View File

@@ -83,9 +83,7 @@ const getEventAndCustomer = async ({
let internalEntityId = null;
if (event_data.entity_id) {
internalEntityId = customer.entities.find(
(e) => e.id === event_data.entity_id
)?.internal_id;
internalEntityId = customer.entity?.internal_id;
}
const newEvent: EventInsert = {

View File

@@ -100,9 +100,7 @@ const createAndInsertEvent = async ({
const entityId = req.body.entity_id;
let internalEntityId = null;
if (entityId) {
internalEntityId = customer.entities.find(
(e) => e.id === entityId
)?.internal_id;
internalEntityId = customer.entity?.internal_id;
}
const newEvent: EventInsert = {

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

View File

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

View File

@@ -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,
@@ -399,6 +397,7 @@ export const createFullCusProduct = async ({
}
// 3. Deduct existing usages
let deductedCusEnts = addExistingUsagesToCusEnts({
cusEnts: cusEnts,
entitlements: entitlements,
@@ -410,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,
@@ -492,7 +491,7 @@ export const createFullCusProduct = async ({
replaceables: newReplaceables,
});
let rolloverInserts: any = [];
const rolloverInserts: any = [];
for (const operation of rolloverOps) {
rolloverInserts.push(
@@ -504,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(
@@ -525,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,30 +21,23 @@ 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 { getCustomerSub } from "../../attachUtils/convertAttachParams.js";
import {
getCustomerSchedule,
getCustomerSub,
paramsToCurSubSchedule,
} from "../../attachUtils/convertAttachParams.js";
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,
@@ -50,7 +52,7 @@ export const handlePaidProduct = async ({
}) => {
const logger = req.logtail;
let {
const {
org,
customer,
products,
@@ -69,17 +71,19 @@ export const handlePaidProduct = async ({
config,
});
let subscriptions: Stripe.Subscription[] = [];
const subscriptions: Stripe.Subscription[] = [];
const { sub: mergeSub, cusProduct: mergeCusProduct } = await getCustomerSub({
attachParams,
});
let sub: Stripe.Subscription | null = null;
let schedule: Stripe.SubscriptionSchedule | null = null;
let schedule: Stripe.SubscriptionSchedule | null | undefined = null;
let invoice: Stripe.Invoice | undefined;
let trialEndsAt = undefined;
// 1. If merge sub
if (mergeSub && !config.disableMerge) {
if (mergeCusProduct?.free_trial) {
trialEndsAt = isTrialing({
@@ -89,9 +93,7 @@ export const handlePaidProduct = async ({
? mergeCusProduct.trial_ends_at
: undefined;
}
attachParams.freeTrial = null;
// 1. If merged sub is canceled, also add to current schedule
const newItemSet = await paramsToSubItems({
req,
@@ -100,7 +102,7 @@ export const handlePaidProduct = async ({
config,
});
const { updatedSub } = await updateStripeSub2({
const { updatedSub, latestInvoice } = await updateStripeSub2({
req,
attachParams,
curSub: mergeSub,
@@ -111,19 +113,45 @@ export const handlePaidProduct = async ({
sub = updatedSub;
if (mergeSub.cancel_at) {
logger.info("ADD PRODUCT FLOW,CREATING NEW SCHEDULE");
if (latestInvoice) {
invoice = await insertInvoiceFromAttach({
db: req.db,
stripeInvoice: latestInvoice,
attachParams,
logger,
});
}
if (subIsCanceled({ sub: mergeSub })) {
logger.info("ADD PRODUCT FLOW, CREATING NEW SCHEDULE");
schedule = await subToNewSchedule({
req,
sub: mergeSub,
attachParams,
config,
endOfBillingPeriod: mergeSub.cancel_at,
endOfBillingPeriod: mergeSub.cancel_at!,
removeCusProducts: attachParams.cusProducts.filter((cp) => cp.canceled),
});
} else {
const res = await getCustomerSchedule({
attachParams,
subId: mergeSub.id,
logger,
});
schedule = res.schedule;
logger.info(`ADD PRODUCT FLOW, SCHEDULE ID: ${schedule?.id}`);
if (schedule) {
await handleUpgradeFlowSchedule({
req,
logger,
attachParams,
config,
schedule,
curSub: mergeSub,
removeCusProducts: [],
fromAddProduct: true,
});
}
}
// 1.
} else {
let billingCycleAnchorUnix = undefined;
const smallestInterval = getSmallestInterval({
@@ -152,6 +180,7 @@ export const handlePaidProduct = async ({
billingCycleAnchorUnix = attachParams.billingAnchor;
}
// console.log("Item set: ", itemSet);
try {
sub = await createStripeSub2({
db: req.db,
@@ -162,6 +191,15 @@ export const handlePaidProduct = async ({
config,
logger,
});
if (sub?.latest_invoice) {
invoice = await insertInvoiceFromAttach({
db: req.db,
stripeInvoice: sub.latest_invoice as Stripe.Invoice,
attachParams,
logger,
});
}
} catch (error: any) {
if (
error instanceof RecaseError &&
@@ -182,16 +220,6 @@ export const handlePaidProduct = async ({
subscriptions.push(sub);
let invoice: Stripe.Invoice | undefined;
if (sub?.latest_invoice) {
invoice = await insertInvoiceFromAttach({
db: req.db,
stripeInvoice: sub.latest_invoice as Stripe.Invoice,
attachParams,
logger,
});
}
const anchorToUnix = getEarliestPeriodEnd({ sub }) * 1000;
if (config.invoiceCheckout) {
@@ -216,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,
})
);
@@ -224,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

@@ -9,7 +9,7 @@ import {
paramsToCurSub,
} from "../attachUtils/convertAttachParams.js";
import { paramsToScheduleItems } from "../mergeUtils/paramsToScheduleItems.js";
import { AttachConfig, SuccessCode } from "@autumn/shared";
import { AttachConfig, AttachScenario, SuccessCode } from "@autumn/shared";
import { CusProductService } from "@/internal/customers/cusProducts/CusProductService.js";
import {
cusProductToSchedule,
@@ -19,6 +19,8 @@ import { subToNewSchedule } from "../mergeUtils/subToNewSchedule.js";
import { getLatestPeriodEnd } from "@/external/stripe/stripeSubUtils/convertSubUtils.js";
import { updateCurSchedule } from "../mergeUtils/updateCurSchedule.js";
import { subItemInCusProduct } from "@/external/stripe/stripeSubUtils/stripeSubItemUtils.js";
import { addProductsUpdatedWebhookTask } from "@/internal/analytics/handlers/handleProductsUpdated.js";
import { addSubIdToCache } from "../../cusCache/subCacheUtils.js";
export const handleRenewProduct = async ({
req,
@@ -33,9 +35,9 @@ export const handleRenewProduct = async ({
}) => {
const logger = req.logtail;
const { stripeCli, customer: fullCus } = attachParams;
const { curScheduledProduct } = attachParamToCusProducts({ attachParams });
const curCusProduct = attachParamsToCurCusProduct({ attachParams });
const { curScheduledProduct } = attachParamToCusProducts({ attachParams });
const product = attachParams.products[0];
const cusProducts = attachParams.customer.customer_products;
@@ -91,6 +93,11 @@ export const handleRenewProduct = async ({
}
if (curSubId) {
// Add sub id to upstash
await addSubIdToCache({
subId: curSubId,
scenario: AttachScenario.Renew,
});
await stripeCli.subscriptions.update(curSubId, {
cancel_at: null,
});
@@ -193,6 +200,24 @@ export const handleRenewProduct = async ({
}
}
if (curCusProduct) {
try {
await addProductsUpdatedWebhookTask({
req,
internalCustomerId: curCusProduct.internal_customer_id,
org: attachParams.org,
env: attachParams.customer.env,
customerId:
attachParams.customer.id || attachParams.customer.internal_id,
scenario: AttachScenario.Renew,
cusProduct: curCusProduct,
logger,
});
} catch (error) {
logger.error("RENEW FLOW: failed to add to webhook queue", { error });
}
}
if (curScheduledProduct) {
await CusProductService.delete({
db: req.db,

View File

@@ -26,6 +26,7 @@ import { subToNewSchedule } from "../../mergeUtils/subToNewSchedule.js";
import { updateCurSchedule } from "../../mergeUtils/updateCurSchedule.js";
import { subItemInCusProduct } from "@/external/stripe/stripeSubUtils/stripeSubItemUtils.js";
import { getCurrentPhaseIndex } from "../../mergeUtils/phaseUtils/phaseUtils.js";
import { addProductsUpdatedWebhookTask } from "@/internal/analytics/handlers/handleProductsUpdated.js";
export const handleScheduleFunction2 = async ({
req,
@@ -138,6 +139,9 @@ export const handleScheduleFunction2 = async ({
logger.info(`SCHEDULE FLOW: no schedule, canceling sub ${curSub?.id}`);
await stripeCli.subscriptions.update(curSub!.id, {
cancel_at: expectedEnd!,
cancellation_details: {
comment: "autumn_downgrade",
},
});
}
@@ -150,13 +154,36 @@ export const handleScheduleFunction2 = async ({
nextResetAt: expectedEnd! * 1000,
disableFreeTrial: true,
isDowngrade: true,
scenario: newProductFree
? AttachScenario.Cancel
: AttachScenario.Downgrade,
sendWebhook: false,
// scenario: newProductFree
// ? AttachScenario.Cancel
// : AttachScenario.Downgrade,
logger,
});
}
if (curCusProduct) {
try {
await addProductsUpdatedWebhookTask({
req,
internalCustomerId: curCusProduct.internal_customer_id,
org: attachParams.org,
env: attachParams.customer.env,
customerId:
attachParams.customer.id || attachParams.customer.internal_id,
scenario: isFreeProduct(attachParams.prices)
? AttachScenario.Cancel
: AttachScenario.Downgrade,
cusProduct: curCusProduct,
logger,
});
} catch (error) {
logger.error("SCHEDULE FLOW: failed to add to webhook queue", { error });
}
}
let apiVersion = attachParams.apiVersion || APIVersion.v1;
if (res) {

View File

@@ -1,4 +1,5 @@
import {
calculateProrationAmount,
Feature,
FeatureOptions,
FullCusProduct,
@@ -76,9 +77,12 @@ export const handleQuantityDowngrade = async ({
)
.toNumber();
const diffWithBillingUnits = new Decimal(difference)
.mul((cusPrice.price.config as UsagePriceConfig).billing_units || 1)
.toNumber();
const billingUnits =
(cusPrice.price.config as UsagePriceConfig).billing_units || 1;
// const diffWithBillingUnits = new Decimal(difference)
// .mul((cusPrice.price.config as UsagePriceConfig).billing_units || 1)
// .toNumber();
const newSubItemQuantity = new Decimal(subItem.quantity || 0)
.plus(subItemDifference)
@@ -91,14 +95,24 @@ export const handleQuantityDowngrade = async ({
const createDowngradeInvoice = async () => {
const { start, end } = subToPeriodStartEnd({ sub: stripeSub });
const amount = priceToInvoiceAmount({
const prevAmount = priceToInvoiceAmount({
price: cusPrice.price,
quantity: diffWithBillingUnits,
proration: {
start: start * 1000,
end: end * 1000,
},
now: attachParams.now,
quantity: new Decimal(oldOptions.quantity).mul(billingUnits!).toNumber(),
});
const newAmount = priceToInvoiceAmount({
price: cusPrice.price,
quantity: new Decimal(newOptions.quantity).mul(billingUnits!).toNumber(),
});
let amount = new Decimal(newAmount).minus(prevAmount).toNumber();
amount = calculateProrationAmount({
periodEnd: end * 1000,
periodStart: start * 1000,
now: attachParams.now || Date.now(),
amount,
allowNegative: true,
});
const product = cusProductToProduct({ cusProduct });
@@ -186,7 +200,6 @@ export const handleQuantityDowngrade = async ({
if (cusEnt) {
const config = cusPrice.price.config as UsagePriceConfig;
const billingUnits = config.billing_units || 1;
let decrementBy = new Decimal(oldOptions.quantity)
.minus(new Decimal(newOptions.quantity))
.mul(billingUnits)

View File

@@ -1,8 +1,10 @@
import {
calculateProrationAmount,
Feature,
FeatureOptions,
FullCusProduct,
FullCustomerPrice,
getAmountForQuantity,
getFeatureInvoiceDescription,
OnIncrease,
UsagePriceConfig,
@@ -69,6 +71,9 @@ export const handleQuantityUpgrade = async ({
OnIncrease.ProrateImmediately;
const prorate = shouldProrate(onIncrease);
const config = cusPrice.price.config as UsagePriceConfig;
const billingUnits = config.billing_units || 1;
const diffWithBillingUnits = new Decimal(difference)
.mul((cusPrice.price.config as UsagePriceConfig).billing_units || 1)
.toNumber();
@@ -76,20 +81,38 @@ export const handleQuantityUpgrade = async ({
if (prorate && stripeSub?.status !== "trialing") {
const { start, end } = subToPeriodStartEnd({ sub: stripeSub });
const amount = priceToInvoiceAmount({
const prevAmount = priceToInvoiceAmount({
price: cusPrice.price,
quantity: diffWithBillingUnits,
proration: prorate
? {
start: start * 1000,
end: end * 1000,
}
: undefined,
now,
quantity: new Decimal(oldOptions.quantity).mul(billingUnits!).toNumber(),
});
const config = cusPrice.price.config as UsagePriceConfig;
const billingUnits = config.billing_units;
const newAmount = priceToInvoiceAmount({
price: cusPrice.price,
quantity: new Decimal(newOptions.quantity).mul(billingUnits!).toNumber(),
});
let amount = new Decimal(newAmount).minus(prevAmount).toNumber();
if (prorate) {
amount = calculateProrationAmount({
periodEnd: end * 1000,
periodStart: start * 1000,
now: now || Date.now(),
amount,
});
}
// const amount = priceToInvoiceAmount({
// price: cusPrice.price,
// quantity: diffWithBillingUnits,
// proration: prorate
// ? {
// start: start * 1000,
// end: end * 1000,
// }
// : undefined,
// now,
// });
const feature = features.find(
(f: Feature) => f.internal_id == newOptions.internal_feature_id
)!;
@@ -160,8 +183,7 @@ export const handleQuantityUpgrade = async ({
});
// Update cus ent
const config = cusPrice.price.config as UsagePriceConfig;
const billingUnits = config.billing_units || 1;
let cusEnt = getRelatedCusEnt({
cusPrice,
cusEnts: cusProduct.customer_entitlements,

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({
@@ -207,6 +208,7 @@ export const handleUpgradeFlow = async ({
carryExistingUsages: config.carryUsage,
carryOverTrial: config.carryTrial,
anchorToUnix: anchorToUnix,
scenario: AttachScenario.Upgrade,
logger,
});
}

View File

@@ -24,6 +24,7 @@ export const handleUpgradeFlowSchedule = async ({
curSub,
removeCusProducts,
logger,
fromAddProduct = false,
}: {
req: ExtendedRequest;
attachParams: AttachParams;
@@ -32,17 +33,17 @@ export const handleUpgradeFlowSchedule = async ({
curSub: Stripe.Subscription;
removeCusProducts?: FullCusProduct[];
logger: any;
fromAddProduct?: boolean;
}) => {
logger.info(`UPGRADE FLOW, updating schedule ${schedule?.id}`);
if (fromAddProduct) {
logger.info(`ADD PRODUCT FLOW, updating schedule ${schedule?.id}`);
} else {
logger.info(`UPGRADE FLOW, updating schedule ${schedule?.id}`);
}
const { stripeCli, customer, prices } = attachParams;
const curCusProduct = attachParamsToCurCusProduct({ attachParams });
// console.log("CUR ITEMS:");
// await logPhases({
// phases: schedule.phases as any,
// db: req.db,
// });
const currentPhaseIndex = getCurrentPhaseIndex({
schedule,
now: attachParams.now,

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

@@ -1,6 +1,6 @@
import { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js";
import { getExistingCusProducts } from "../../cusProducts/cusProductUtils/getExistingCusProducts.js";
import { cusProductToProduct } from "@autumn/shared";
import { CusProductStatus, cusProductToProduct } from "@autumn/shared";
import Stripe from "stripe";
export const attachParamsToCurCusProduct = ({
@@ -134,19 +134,6 @@ export const getCustomerSub = async ({
// If there's only one customer product on sub, and it's still trialing, return undefined, because should just replace sub.
const curCusProduct = attachParamsToCurCusProduct({ attachParams });
// const cusProductsOnSub = cusProducts.filter(
// (cp) =>
// cp.subscription_ids?.includes(cusProduct.subscription_ids![0]) &&
// curCusProduct?.id !== cp.id
// );
// if (
// cusProductsOnSub.length === 1 &&
// isTrialing({ cusProduct, now: attachParams.now })
// ) {
// return { sub: undefined, cusProduct: undefined };
// }
const sub = await stripeCli.subscriptions.retrieve(subId, {
expand: [
"items.data.price.tiers",
@@ -158,6 +145,80 @@ export const getCustomerSub = async ({
return { subId, sub, cusProduct };
};
export const getCustomerSchedule = async ({
attachParams,
subId,
logger,
}: {
attachParams: AttachParams;
subId?: string;
logger: any;
}) => {
const { stripeCli } = attachParams;
const fullCus = attachParams.customer;
let cusProducts = fullCus.customer_products;
const targetGroup = attachParams.products[0].group;
const targetEntityId = attachParams.internalEntityId || null;
const targetProductId = attachParams.products[0].id;
cusProducts.sort((a, b) => {
// 1. Check same group
const aGroupMatches = a.product.group === targetGroup;
const bGroupMatches = b.product.group === targetGroup;
if (aGroupMatches && !bGroupMatches) return -1;
if (!aGroupMatches && bGroupMatches) return 1;
// 2. Check main product
const aMain = !a.product.is_add_on;
const bMain = !b.product.is_add_on;
if (aMain && !bMain) return -1;
if (!aMain && bMain) return 1;
// 3. Check same product
const aProductIdMatches = a.product.id === targetProductId;
const bProductIdMatches = b.product.id === targetProductId;
if (aProductIdMatches && !bProductIdMatches) return -1;
if (!aProductIdMatches && bProductIdMatches) return 1;
// 4. Check same entity
const aEntityIdMatches = (a.internal_entity_id || null) === targetEntityId;
const bEntityIdMatches = (b.internal_entity_id || null) === targetEntityId;
if (aEntityIdMatches && !bEntityIdMatches) return -1;
if (!aEntityIdMatches && bEntityIdMatches) return 1;
return 0;
});
// const subId = cusProducts.flatMap((cp) => cp.subscription_ids || [])?.[0];
const scheduleIds = cusProducts.flatMap((cp) => cp.scheduled_ids || []);
if (scheduleIds.length === 0) return { schedule: undefined };
try {
const schedules = await stripeCli.subscriptionSchedules.list({
customer: fullCus.processor.id!,
expand: ["data.phases.items.price"],
});
const schedule = schedules.data.find(
(schedule) =>
scheduleIds.includes(schedule.id) &&
(subId ? schedule.subscription === subId : true)
);
return { schedule };
} catch (error: any) {
logger.error(`Error getting schedule, ids: ${scheduleIds}`, {
message: error.message,
});
return { schedule: undefined };
}
};
export const paramsToCurSub = async ({
attachParams,
}: {
@@ -206,11 +267,5 @@ export const paramsToCurSubSchedule = async ({
return undefined;
}
// const batchPricesGet = [];
// for (const item of schedule.phases[0].items) {
// batchPricesGet.push(stripeCli.prices.retrieve(item.price as string));
// }
// const prices = await Promise.all(batchPricesGet);
return schedule as Stripe.SubscriptionSchedule;
};

View File

@@ -99,9 +99,7 @@ const getOptionsToUpdate = ({
internalFeatureId: internalFeatureId!,
});
if (price?.config.interval == BillingInterval.OneOff) {
continue;
}
if (price?.config.interval == BillingInterval.OneOff) continue;
if (existingOptions && existingOptions.quantity !== newOptions.quantity) {
optionsToUpdate.push({
@@ -117,9 +115,13 @@ const getOptionsToUpdate = ({
export const checkSameCustom = async ({
attachParams,
curSameProduct,
fromPreview,
optionsToUpdate,
}: {
attachParams: AttachParams;
curSameProduct: FullCusProduct;
fromPreview?: boolean;
optionsToUpdate: { new: FeatureOptions; old: FeatureOptions }[];
}) => {
let product = attachParams.products[0];
@@ -136,6 +138,19 @@ export const checkSameCustom = async ({
});
if (itemsSame && freeTrialsSame) {
if (
fromPreview &&
hasPrepaidPrice({ prices: attachParams.prices, excludeOneOff: true })
) {
return AttachBranch.UpdatePrepaidQuantity;
}
// 1. If prepaid quantity changed
if (optionsToUpdate.length > 0) {
attachParams.optionsToUpdate = optionsToUpdate;
return AttachBranch.UpdatePrepaidQuantity;
}
throw new RecaseError({
message: `Items specified for ${product.name} are the same as the existing product, can't attach again`,
code: ErrCode.InvalidRequest,
@@ -175,29 +190,25 @@ const getSameProductBranch = async ({
return AttachBranch.NewVersion;
}
// 2. Same custom?
if (attachParams.isCustom && curScheduledProduct?.product.id !== product.id) {
return await checkSameCustom({ attachParams, curSameProduct });
}
let optionsToUpdate = getOptionsToUpdate({
oldOptionsList: curSameProduct.options,
newOptionsList: attachParams.optionsList,
curSameProduct,
});
// 2. Same custom?
if (attachParams.isCustom && curScheduledProduct?.product.id !== product.id) {
return await checkSameCustom({
attachParams,
curSameProduct,
fromPreview,
optionsToUpdate,
});
}
// 1. If prepaid quantity changed
if (optionsToUpdate.length > 0) {
attachParams.optionsToUpdate = optionsToUpdate;
if (attachParams.isCustom) {
throw new RecaseError({
message: `Not allowed to update prepaid quantity for current product if is_custom is true`,
code: ErrCode.InternalError,
statusCode: 500,
});
}
return AttachBranch.UpdatePrepaidQuantity;
}

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

@@ -184,8 +184,6 @@ export const handleCheckout = (req: any, res: any) =>
if (checkoutRes.next_cycle) {
const nextCycle = checkoutRes.next_cycle;
console.log("Due at:", formatUnixToDate(nextCycle.starts_at!));
console.log("Total:", nextCycle.total);
}
res.status(200).json({

View File

@@ -30,9 +30,6 @@ export const handleAttachPreview = (req: any, res: any) =>
logger,
});
// console.log("Branch:", attachPreview.branch);
// console.log("Func:", attachPreview.func);
res.status(200).json(attachPreview);
return;

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

@@ -0,0 +1,21 @@
import { initUpstash } from "./upstashUtils.js";
export const addSubIdToCache = async ({
subId,
scenario,
}: {
subId: string;
scenario: string;
}) => {
const upstash = await initUpstash();
if (!upstash) return;
await upstash.set(`sub:${subId}`, scenario, {
ex: 180, // 3 minutes
});
};
export const getSubScenarioFromCache = async ({ subId }: { subId: string }) => {
const upstash = await initUpstash();
if (!upstash) return null;
return (await upstash.get(`sub:${subId}`)) as string | null;
};

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

@@ -119,132 +119,6 @@ export const getCusEntBalance = ({
};
};
// export const sortCusEntsForDeduction = (
// cusEnts: (FullCustomerEntitlement & {
// customer_product?: FullCusProduct;
// })[],
// reverseOrder: boolean = false
// ) => {
// let intervalOrder: Record<EntInterval, number> = {
// [EntInterval.Minute]: 0, // 1 minute
// [EntInterval.Hour]: 1, // 1 hour
// [EntInterval.Day]: 2, // 1 day
// [EntInterval.Week]: 3, // 1 week
// [EntInterval.Month]: 4, // 1 month
// [EntInterval.Quarter]: 5, // 3 months
// [EntInterval.Year]: 6, // 1 year
// [EntInterval.SemiAnnual]: 7, // 6 months
// [EntInterval.Lifetime]: 8, // 1 time
// };
// // console.log(
// // `Cus ents before (${reverseOrder ? "reversed" : "normal"})`,
// // cusEnts.map(
// // (ce) => `${ce.entitlement.feature_id} - ${ce.entitlement.interval}`
// // )
// // );
// cusEnts.sort((a, b) => {
// const aEnt = a.entitlement;
// const bEnt = b.entitlement;
// // 1. If boolean, go first
// if (aEnt.feature.type == FeatureType.Boolean) {
// return -1;
// }
// if (bEnt.feature.type == FeatureType.Boolean) {
// return 1;
// }
// // 1. If a is credit system and b is not, a should go last
// if (
// aEnt.feature.type == FeatureType.CreditSystem &&
// bEnt.feature.type != FeatureType.CreditSystem
// ) {
// return 1;
// }
// // 2. If a is not credit system and b is, a should go first
// if (
// aEnt.feature.type != FeatureType.CreditSystem &&
// bEnt.feature.type == FeatureType.CreditSystem
// ) {
// return -1;
// }
// // 2. Sort by unlimited (unlimited goes first)
// if (
// aEnt.allowance_type == AllowanceType.Unlimited &&
// bEnt.allowance_type != AllowanceType.Unlimited
// ) {
// return -1;
// }
// if (
// aEnt.allowance_type != AllowanceType.Unlimited &&
// bEnt.allowance_type == AllowanceType.Unlimited
// ) {
// return 1;
// }
// // If one has usage_allowed, it should go last
// if (!a.usage_allowed && b.usage_allowed) {
// return -1;
// }
// if (!b.usage_allowed && a.usage_allowed) {
// return 1;
// }
// // If one has a next_reset_at, it should go first
// let nextResetFirst = reverseOrder ? 1 : -1;
// if (a.next_reset_at && !b.next_reset_at) {
// return nextResetFirst;
// }
// // If b has a next_reset_at, it should go first
// if (!a.next_reset_at && b.next_reset_at) {
// return -nextResetFirst;
// }
// // 3. Sort by interval
// let aVal = entIntervalToValue(aEnt.interval, aEnt.interval_count);
// let bVal = entIntervalToValue(bEnt.interval, bEnt.interval_count);
// if (aEnt.interval && bEnt.interval && !aVal.eq(bVal)) {
// if (reverseOrder) {
// return bVal.sub(aVal).toNumber();
// // return intervalOrder[bEnt.interval] - intervalOrder[aEnt.interval];
// } else {
// return aVal.sub(bVal).toNumber();
// // return intervalOrder[aEnt.interval] - intervalOrder[bEnt.interval];
// }
// }
// // Check if a is main product
// let aIsAddOn = a.customer_product?.product?.is_add_on;
// let bIsAddOn = b.customer_product?.product?.is_add_on;
// if (aIsAddOn && !bIsAddOn) {
// return 1;
// }
// if (!aIsAddOn && bIsAddOn) {
// return -1;
// }
// // 4. Sort by created_at
// return a.created_at - b.created_at;
// });
// // console.log(
// // `Cus ents after (${reverseOrder ? "reversed" : "normal"})`,
// // cusEnts.map(
// // (ce) => `${ce.entitlement.feature_id} - ${ce.entitlement.interval}`
// // )
// // );
// };
// Get related cusPrice
export const getRelatedCusPrice = (
cusEnt: FullCustomerEntitlement,

View File

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

View File

@@ -1,7 +1,6 @@
import RecaseError from "@/utils/errorUtils.js";
import { nullish } from "@/utils/genUtils.js";
import { ErrCode, FullCustomerEntitlement } from "@autumn/shared";
import { StatusCodes } from "http-status-codes";
import { FullCustomerEntitlement } from "@autumn/shared";
export const getEntityBalance = ({
cusEnt,
@@ -13,14 +12,7 @@ export const getEntityBalance = ({
let entityBalance = cusEnt.entities?.[entityId!]?.balance;
let adjustment = cusEnt.entities?.[entityId!]?.adjustment || 0;
if (nullish(entityBalance)) {
return { balance: 0, adjustment: 0 };
// throw new RecaseError({
// message: `Entity balance not found for entityId: ${entityId}`,
// code: ErrCode.EntityBalanceNotFound,
// statusCode: StatusCodes.BAD_REQUEST,
// });
}
if (nullish(entityBalance)) return { balance: 0, adjustment: 0 };
return {
balance: entityBalance,
@@ -45,11 +37,11 @@ export const getSummedEntityBalances = ({
return {
balance: Object.values(cusEnt.entities!).reduce(
(acc, curr) => acc + curr.balance,
0,
0
),
adjustment: Object.values(cusEnt.entities!).reduce(
(acc, curr) => acc + curr.adjustment,
0,
0
),
unused: 0,
count: Object.values(cusEnt.entities!).length,

View File

@@ -15,11 +15,11 @@ import { notNullish, notNullOrUndefined } from "@/utils/genUtils.js";
import { BREAK_API_VERSION } from "@/utils/constants.js";
import {
getCusEntBalance,
getRelatedCusPrice,
getResetBalance,
getUnlimitedAndUsageAllowed,
} from "../../cusProducts/cusEnts/cusEntUtils.js";
import { getCusEntBalance } from "@autumn/shared";
export interface CusFeatureBalance {
feature_id: string;

View File

@@ -41,16 +41,16 @@ export const getCusProductResponse = async ({
cusProduct,
subs,
org,
entities = [],
apiVersion,
features,
entity,
}: {
cusProduct: FullCusProduct;
org: Organization;
subs?: Subscription[];
entities?: Entity[];
apiVersion: number;
features: Feature[];
entity?: Entity;
}) => {
// Process prices
@@ -184,11 +184,12 @@ export const getCusProductResponse = async ({
// stripe_subscription_ids: cusProduct.subscription_ids || [],
started_at: cusProduct.starts_at,
entity_id: cusProduct.internal_entity_id
? entities?.find(
(e: Entity) => e.internal_id == cusProduct.internal_entity_id
)?.id
: cusProduct.entity_id || undefined,
entity_id: entity?.id || cusProduct.entity_id || undefined,
// entity_id: cusProduct.internal_entity_id
// ? entities?.find(
// (e: Entity) => e.internal_id == cusProduct.internal_entity_id
// )?.id
// : cusProduct.entity_id || undefined,
...stripeSubData,
items: v2Product.items,

View File

@@ -48,14 +48,14 @@ export const processFullCusProducts = async ({
fullCusProducts,
subs,
org,
entities = [],
entity,
apiVersion,
features,
}: {
fullCusProducts: any;
subs: any;
org: Organization;
entities?: Entity[];
entity?: Entity;
apiVersion: number;
features: Feature[];
}) => {
@@ -67,7 +67,7 @@ export const processFullCusProducts = async ({
cusProduct,
subs,
org,
entities,
entity,
apiVersion,
features,
});

View File

@@ -132,7 +132,7 @@ export const getCusEntsInFeatures = async ({
if (internalFeatureIds) {
cusEnts = cusEnts.filter((cusEnt) =>
internalFeatureIds.includes(cusEnt.internal_feature_id)
internalFeatureIds.includes(cusEnt.entitlement.internal_feature_id)
);
}
@@ -142,7 +142,6 @@ export const getCusEntsInFeatures = async ({
(cusEnt) =>
nullish(cusEnt.customer_product.internal_entity_id) ||
cusEnt.customer_product.internal_entity_id === entity.internal_id
// || cusEnt.entities
);
}

View File

@@ -82,7 +82,6 @@ export const getCustomerDetails = async ({
subs,
org,
apiVersion,
entities: customer.entities,
features,
});

View File

@@ -175,6 +175,7 @@ const buildInvoicesCTE = (hasEntityCTE: boolean) => {
? sql`AND (
NOT EXISTS (SELECT 1 FROM entity_record)
OR i.internal_entity_id = (SELECT internal_id FROM entity_record LIMIT 1)
OR i.internal_entity_id IS NULL
)`
: sql``;

View File

@@ -7,6 +7,40 @@ import RecaseError, { handleRequestError } from "@/utils/errorUtils.js";
import { routeHandler } from "@/utils/routerUtils.js";
import { ErrCode } from "@autumn/shared";
import { StatusCodes } from "http-status-codes";
import Stripe from "stripe";
const createDefaultBillingPortalConfiguration = async (stripeCli: Stripe) => {
try {
const configuration = await stripeCli.billingPortal.configurations.create({
features: {
customer_update: {
allowed_updates: ["email", "address"],
enabled: true,
},
invoice_history: {
enabled: true,
},
payment_method_update: {
enabled: true,
},
subscription_cancel: {
enabled: true,
},
},
// business_profile: {
// privacy_policy_url: "https://example.com/privacy",
// terms_of_service_url: "https://example.com/terms",
// },
});
return configuration;
} catch (error: any) {
throw new RecaseError({
message: `Failed to create billing portal configuration: ${error.message}`,
code: ErrCode.StripeError,
statusCode: StatusCodes.INTERNAL_SERVER_ERROR,
});
}
};
export const handleCreateBillingPortal = async (req: any, res: any) =>
routeHandler({
@@ -37,23 +71,19 @@ export const handleCreateBillingPortal = async (req: any, res: any) =>
const stripeCli = createStripeCli({ org, env: req.env });
// Determine the Stripe customer ID to use
let stripeCustomerId: string;
if (!customer.processor?.id) {
let newCus;
try {
newCus = await createStripeCusIfNotExists({
const newCus = await createStripeCusIfNotExists({
db: req.db,
org,
env: req.env,
customer,
logger: req.logtail,
});
} catch (error: any) {
throw new RecaseError({
message: `Failed to create Stripe customer`,
code: ErrCode.StripeError,
statusCode: StatusCodes.INTERNAL_SERVER_ERROR,
});
} finally {
if (!newCus) {
throw new RecaseError({
message: `Failed to create Stripe customer`,
@@ -62,26 +92,78 @@ export const handleCreateBillingPortal = async (req: any, res: any) =>
});
}
const portal = await stripeCli.billingPortal.sessions.create({
customer: newCus.id,
return_url: returnUrl || toSuccessUrl({ org, env: req.env }),
});
res.status(200).json({
customer_id: customer.id,
url: portal.url,
stripeCustomerId = newCus.id;
} catch (error: any) {
throw new RecaseError({
message: `Failed to create Stripe customer`,
code: ErrCode.StripeError,
statusCode: StatusCodes.INTERNAL_SERVER_ERROR,
});
}
} else {
const portal = await stripeCli.billingPortal.sessions.create({
customer: customer.processor.id,
stripeCustomerId = customer.processor.id;
}
// Create billing portal session
let portal;
try {
portal = await stripeCli.billingPortal.sessions.create({
customer: stripeCustomerId,
return_url: returnUrl || toSuccessUrl({ org, env: req.env }),
});
} catch (error: any) {
console.log(`Code: ${error.code}, Message: ${error.message}`);
res.status(200).json({
customer_id: customer.id,
url: portal.url,
});
// Check if the error is due to missing default configuration
if (
error.message &&
error.message.includes("default configuration has not been created")
) {
try {
// Create a default billing portal configuration
req.logtail?.info(
`Creating default billing portal configuration for customer ${customer.id}`
);
const configuration =
await createDefaultBillingPortalConfiguration(stripeCli);
req.logtail?.info(
"Successfully created billing portal configuration",
{
configurationId: configuration.id,
orgId: org.id,
}
);
// Retry creating the portal session with the new configuration
portal = await stripeCli.billingPortal.sessions.create({
customer: stripeCustomerId,
return_url: returnUrl || toSuccessUrl({ org, env: req.env }),
configuration: configuration.id,
});
} catch (configError: any) {
req.logtail?.error(
"Failed to create billing portal configuration",
{
error: configError.message,
orgId: org.id,
}
);
throw new RecaseError({
message: `Failed to create billing portal configuration: ${configError.message}`,
code: ErrCode.StripeError,
statusCode: StatusCodes.INTERNAL_SERVER_ERROR,
});
}
} else {
throw error;
}
}
res.status(200).json({
customer_id: customer.id,
url: portal.url,
});
},
});

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

@@ -3,12 +3,13 @@ import { z } from "zod";
import { ExtendedRequest, ExtendedResponse } from "@/utils/models/Request.js";
import { routeHandler } from "@/utils/routerUtils.js";
import { CusService } from "../CusService.js";
import { ErrCode } from "@autumn/shared";
import { AttachScenario, ErrCode } from "@autumn/shared";
import { CusProductService } from "../cusProducts/CusProductService.js";
import { nullish } from "@/utils/genUtils.js";
import { handleDecreaseAndTransfer } from "./handleTransferProduct/handleDecreaseAndTransfer.js";
import { ProductService } from "@/internal/products/ProductService.js";
import { deleteCusCache } from "../cusCache/updateCachedCus.js";
import { addProductsUpdatedWebhookTask } from "@/internal/analytics/handlers/handleProductsUpdated.js";
const TransferProductSchema = z.object({
from_entity_id: z.string().nullish(),
to_entity_id: z.string(),
@@ -119,6 +120,21 @@ export const handleTransferProduct = async (req: any, res: any) =>
internal_entity_id: toEntity.internal_id,
},
});
await addProductsUpdatedWebhookTask({
req,
internalCustomerId: customer.internal_id,
org: req.org,
env: req.env,
customerId: customer.id || customer.internal_id,
scenario: AttachScenario.New,
cusProduct: {
...cusProduct,
entity_id: toEntity.id,
internal_entity_id: toEntity.internal_id,
},
logger: req.logger,
});
}
await deleteCusCache({

View File

@@ -100,6 +100,6 @@ export const handleDecreaseAndTransfer = async ({
},
product
),
sendWebhook: false,
scenario: AttachScenario.New,
});
};

View File

@@ -13,10 +13,8 @@ import {
} from "@/trigger/updateBalanceTask.js";
import { OrgService } from "@/internal/orgs/OrgService.js";
import {
getCusEntBalance,
getUnlimitedAndUsageAllowed,
} from "@/internal/customers/cusProducts/cusEnts/cusEntUtils.js";
import { getUnlimitedAndUsageAllowed } from "@/internal/customers/cusProducts/cusEnts/cusEntUtils.js";
import { getCusEntBalance } from "@autumn/shared";
import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService.js";
import { notNullish } from "@/utils/genUtils.js";

View File

@@ -9,10 +9,7 @@ import { adjustAllowance } from "@/trigger/adjustAllowance.js";
import { CusPriceService } from "@/internal/customers/cusProducts/cusPrices/CusPriceService.js";
import { CusService } from "@/internal/customers/CusService.js";
import { OrgService } from "@/internal/orgs/OrgService.js";
import {
getCusEntBalance,
getRelatedCusPrice,
} from "@/internal/customers/cusProducts/cusEnts/cusEntUtils.js";
import { getCusEntBalance } from "@autumn/shared";
import { performDeductionOnCusEnt } from "@/trigger/updateBalanceTask.js";
import { ExtendedRequest } from "@/utils/models/Request.js";
import { DrizzleCli } from "@/db/initDrizzle.js";

View File

@@ -123,7 +123,7 @@ export class FeatureService {
env: updatedFeatures[0].env as AppEnv,
});
return updatedFeatures.length > 0 ? updatedFeatures[0] : null;
return updatedFeatures.length > 0 ? (updatedFeatures[0] as Feature) : null;
}
static async insert({

View File

@@ -44,6 +44,29 @@ featureRouter.get("", async (req: any, res: any) =>
})
);
featureRouter.get("/:featureId", async (req: any, res: any) =>
routeHandler({
req,
res,
action: "Get feature",
handler: async () => {
const feature = req.features.find(
(f: Feature) => f.id == req.params.featureId
);
if (!feature) {
throw new RecaseError({
message: `Feature with id ${req.params.featureId} not found`,
code: ErrCode.FeatureNotFound,
statusCode: 404,
});
}
res.status(200).json(toAPIFeature({ feature }));
},
})
);
featureRouter.post("", async (req: any, res: any) =>
routeHandler({
req,

View File

@@ -289,7 +289,13 @@ export const handleUpdateFeature = async (
});
if (res) {
res.status(200).json({ success: true, feature_id: featureId });
res
.status(200)
.json(
updatedFeature
? toAPIFeature({ feature: updatedFeature })
: undefined
);
}
return;
}
@@ -394,20 +400,10 @@ export const handleUpdateFeature = async (
});
}
if (res && fromApi) {
let newFeature = await FeatureService.get({
db: req.db,
id: featureId,
orgId: req.orgId,
env: req.env,
});
res.status(200).json(toAPIFeature({ feature: newFeature }));
} else {
res.status(200).json({ success: true, feature_id: featureId });
}
// if (res) {
// res.status(200).json({ success: true, feature_id: featureId });
// }
res
.status(200)
.json(
updatedFeature ? toAPIFeature({ feature: updatedFeature }) : undefined
);
},
});

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

@@ -59,9 +59,7 @@ mainRouter.use(
id: invoiceId,
});
if (!invoice) {
return res.status(404).json({ error: "Invoice not found" });
}
if (!invoice) return res.status(404).json({ error: "Invoice not found" });
try {
let org = invoice.customer.org;
@@ -72,6 +70,12 @@ mainRouter.use(
});
let stripeInvoice = await stripeCli.invoices.retrieve(invoice.stripe_id);
if (stripeInvoice.status == "draft") {
return res
.status(404)
.json({ error: "This invoice is in draft status and has no URL" });
}
res.redirect(stripeInvoice.hosted_invoice_url);
} catch (e) {
console.error(e);

View File

@@ -44,22 +44,20 @@ const deleteStripeWebhooks = async ({
org: Organization;
logger: any;
}) => {
if (org.stripe_config) {
try {
await deleteStripeWebhook({
org: org,
env: AppEnv.Sandbox,
});
try {
await deleteStripeWebhook({
org: org,
env: AppEnv.Sandbox,
});
await deleteStripeWebhook({
org: org,
env: AppEnv.Live,
});
} catch (error: any) {
logger.error(
`Failed to delete stripe webhooks for ${org.id}, ${org.slug}. ${error.message})`
);
}
await deleteStripeWebhook({
org: org,
env: AppEnv.Live,
});
} catch (error: any) {
logger.error(
`Failed to delete stripe webhooks for ${org.id}, ${org.slug}. ${error.message})`
);
}
};

View File

@@ -90,15 +90,18 @@ export const deleteStripeWebhook = async ({
org: Organization;
env: AppEnv;
}) => {
if (!isStripeConnected({ org, env })) return;
const stripeCli = createStripeCli({ org, env });
const webhookEndpoints = await stripeCli.webhookEndpoints.list({
limit: 100,
});
for (const webhook of webhookEndpoints.data) {
if (webhook.url.includes(org.id)) {
if (webhook.url.includes(org.id) && webhook.url.includes(env)) {
try {
await stripeCli.webhookEndpoints.del(webhook.id);
console.log(`Deleted stripe webhook (${env}) ${webhook.url}`);
} catch (error: any) {
console.log(`Failed to delete stripe webhook (${env}) ${webhook.url}`);
console.log(error.message);
@@ -209,3 +212,30 @@ export const updateOrgConfig = async ({
await CacheManager.disconnect();
}
};
export const unsetOrgStripeKeys = async ({
org,
env,
db,
}: {
org: Organization;
env: AppEnv;
db: DrizzleCli;
}) => {
const newStripeConfig: any = structuredClone(org.stripe_config) || {};
if (env === AppEnv.Sandbox) {
newStripeConfig.test_api_key = null;
newStripeConfig.test_webhook_secret = null;
} else {
newStripeConfig.live_api_key = null;
newStripeConfig.live_webhook_secret = null;
}
await OrgService.update({
db,
orgId: org.id,
updates: {
stripe_config: newStripeConfig,
},
});
};

View File

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

View File

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

View File

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

View File

@@ -8,7 +8,7 @@ import {
Price,
UsagePriceConfig,
} from "@autumn/shared";
import { getBillingType } from "../../priceUtils.js";
import { formatPrice, getBillingType } from "../../priceUtils.js";
import { notNullish } from "@/utils/genUtils.js";
import Stripe from "stripe";
import { Decimal } from "decimal.js";
@@ -82,6 +82,7 @@ export const hasPrepaidPrice = ({
return prices.some((price) => {
let isUsage = getBillingType(price.config) == BillingType.UsageInAdvance;
let isOneOff = price.config.interval == BillingInterval.OneOff;
return isUsage && (excludeOneOff ? !isOneOff : true);
});
};

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,

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