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,17 +1,19 @@
import dotenv from "dotenv";
dotenv.config();
import { toSnakeCase } from "@/utils/genUtils.js";
import {
APIVersion,
CreateEntity,
CreateRewardProgram,
type APIVersion,
type AttachBody,
type CreateEntity,
type CreateRewardProgram,
CusExpand,
EntityExpand,
ErrCode,
Invoice,
OrgConfig,
type OrgConfig,
type RewardRedemption,
} from "@autumn/shared";
import {
import type {
CancelParams,
CheckoutParams,
CheckoutResult,
@@ -19,10 +21,8 @@ import {
CheckResult,
Customer,
TrackParams,
TransferProductParams,
UsageParams,
} from "autumn-js";
import { AttachBody } from "@autumn/shared";
export default class AutumnError extends Error {
message: string;
@@ -117,14 +117,14 @@ export class AutumnInt {
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) {
@@ -179,7 +179,7 @@ export class AutumnInt {
return data;
}
async checkout(
params: CheckoutParams & { invoice?: boolean; force_checkout?: boolean }
params: CheckoutParams & { invoice?: boolean; force_checkout?: boolean },
) {
// const data = await this.post(`/attach`, {
// customer_id: customerId,
@@ -196,7 +196,7 @@ export class AutumnInt {
from_entity_id?: string;
to_entity_id: string;
product_id: string;
}
},
) {
const data = await this.post(`/customers/${customerId}/transfer`, params);
@@ -253,7 +253,7 @@ export class AutumnInt {
customerId: string,
params?: {
expand?: CusExpand[];
}
},
): Promise<
Customer & {
invoices: any[];
@@ -270,7 +270,7 @@ export class AutumnInt {
}
const data = await this.get(
`/customers/${customerId}?${queryParams.toString()}`
`/customers/${customerId}?${queryParams.toString()}`,
);
return data;
},
@@ -285,7 +285,7 @@ export class AutumnInt {
deleteInStripe = false,
}: {
deleteInStripe?: boolean;
} = {}
} = {},
) => {
const data = await this.delete(`/customers/${customerId}`, {
deleteInStripe,
@@ -297,19 +297,19 @@ export class AutumnInt {
entities = {
get: async (customerId: string, entityId: string) => {
const data = await this.get(
`/customers/${customerId}/entities/${entityId}?expand=${EntityExpand.Invoices}`
`/customers/${customerId}/entities/${entityId}?expand=${EntityExpand.Invoices}`,
);
return data;
},
create: async (
customerId: string,
entity: CreateEntity | CreateEntity[]
entity: CreateEntity | CreateEntity[],
) => {
// let entities = Array.isArray(entity) ? entity : [entity];
const data = await this.post(
`/customers/${customerId}/entities?with_autumn_id=true`,
entity
entity,
);
return data;
@@ -322,7 +322,7 @@ export class AutumnInt {
delete: async (customerId: string, entityId: string) => {
const data = await this.delete(
`/customers/${customerId}/entities/${entityId}`
`/customers/${customerId}/entities/${entityId}`,
);
return data;
},
@@ -339,10 +339,10 @@ export class AutumnInt {
get: async (
productId: string,
{ v1Schema = false }: { v1Schema?: boolean } = {}
{ v1Schema = false }: { v1Schema?: boolean } = {},
) => {
const data = await this.get(
`/products/${productId}?${v1Schema ? "schemaVersion=1" : ""}`
`/products/${productId}?${v1Schema ? "schemaVersion=1" : ""}`,
);
return data;
},
@@ -414,7 +414,7 @@ export class AutumnInt {
redemptions = {
get: async (redemptionId: string) => {
const data = await this.get(`/redemptions/${redemptionId}`);
return data;
return data as RewardRedemption;
},
};

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,22 +165,28 @@ 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
applies_to:
reward.type === RewardType.FreeProduct
? undefined
: !discountConfig!.apply_to_all
? {
products: stripeProdIds,
}

View File

@@ -1,11 +1,16 @@
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,
@@ -41,11 +46,11 @@ export const createStripeCusIfNotExists = async ({
createNew = true;
} else {
try {
let stripeCus = await stripeCli.customers.retrieve(
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;
@@ -142,7 +147,7 @@ export const listCusPaymentMethods = async ({
stripeCli: Stripe;
stripeId: string;
}) => {
let res = await stripeCli.paymentMethods.list({
const res = await stripeCli.paymentMethods.list({
customer: stripeId,
});
@@ -166,13 +171,14 @@ export const getCusPaymentMethod = async ({
}
const stripeCustomer = (await stripeCli.customers.retrieve(
stripeId
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({
const res = await stripeCli.paymentMethods.list({
customer: stripeId,
});
@@ -193,7 +199,7 @@ export const getCusPaymentMethod = async ({
return paymentMethods[0];
} else {
const paymentMethod = await stripeCli.paymentMethods.retrieve(
paymentMethodId as string
paymentMethodId as string,
);
return paymentMethod;
}
@@ -247,7 +253,7 @@ export const attachPmToCus = async ({
const stripeCli = createStripeCli({ org, env });
try {
let token = willFail ? "tok_chargeCustomerFail" : "tok_visa";
const token = willFail ? "tok_chargeCustomerFail" : "tok_visa";
const pm = await stripeCli.paymentMethods.create({
type: "card",
card: {
@@ -312,7 +318,7 @@ export const deleteAllStripeCustomers = async ({
return;
}
let firstCustomer = stripeCustomers.data[0];
const firstCustomer = stripeCustomers.data[0];
if (firstCustomer.livemode) {
throw new RecaseError({
message: "Cannot delete livemode customers",
@@ -321,12 +327,12 @@ export const deleteAllStripeCustomers = async ({
});
}
let batchSize = 10;
const batchSize = 10;
for (let i = 0; i < stripeCustomers.data.length; i += batchSize) {
let batch = stripeCustomers.data.slice(i, 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`
`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,10 +1,10 @@
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,
@@ -21,7 +21,7 @@ export async function handleCusDiscountDeleted({
logger: any;
res: any;
}) {
let customer = await CusService.getByStripeId({
const customer = await CusService.getByStripeId({
db,
stripeId: discount.customer,
});
@@ -32,37 +32,100 @@ export async function handleCusDiscountDeleted({
}
if (customer.env !== env || customer.org_id !== org.id) {
logger.info(`discount.deleted: env or org mismatch, skipping`);
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({
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({
if (discount.subscription) {
logger.info(
`Discount is a subscription, paidProductRedemption: ${paidProductRedemption?.id}`,
);
if (!paidProductRedemption) return;
// Re-apply coupon and mark applied / redeemer applied to true
const stripeCli = createStripeCli({
org,
env,
});
let stripeCus = (await stripeCli.customers.retrieve(
discount.customer
// Mark reward redemption as applied / redeemer applied to true
const sub = await stripeCli.subscriptions.retrieve(discount.subscription);
// 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;
}
// 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: paidProductRedemption.id,
updates: {
applied: isReferrer ? true : undefined,
redeemer_applied: isReferrer ? undefined : true,
},
});
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`
`discount.deleted: stripe customer ${discount.customer} already has a discount`,
);
return;
}
@@ -76,7 +139,7 @@ export async function handleCusDiscountDeleted({
if (!reward) {
logger.warn(
`discount.deleted: reward ${redemption.reward_program.internal_id} not found`
`discount.deleted: reward ${redemption.reward_program.internal_id} not found`,
);
return;
}
@@ -88,8 +151,8 @@ export async function handleCusDiscountDeleted({
});
await legacyStripe.customers.update(discount.customer, {
// @ts-ignore
coupon: reward.internal_id,
// @ts-expect-error
coupon: reward.id,
});
await RewardRedemptionService.update({
@@ -101,7 +164,7 @@ export async function handleCusDiscountDeleted({
});
logger.info(
`discount.deleted: applied reward ${reward.name} on customer ${customer.name} (${customer.id})`
`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,15 +20,10 @@ 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,
@@ -105,7 +104,7 @@ const convertToChargeAutomatically = async ({
// 2. Get payment method
const paymentMethod = await stripeCli.paymentMethods.retrieve(
paymentIntent.payment_method as string
paymentIntent.payment_method as string,
);
await stripeCli.paymentMethods.attach(paymentMethod.id, {
@@ -121,7 +120,7 @@ const convertToChargeAutomatically = async ({
});
} catch (error) {
logger.warn(
`Convert to charge automatically: error updating subscription ${sub.id}`
`Convert to charge automatically: error updating subscription ${sub.id}`,
);
logger.warn(error);
}
@@ -195,7 +194,7 @@ export const handleInvoicePaid = async ({
// TODO: Send alert
if (invoice.livemode) {
logger.warn(
`invoice.paid: customer product not found for invoice ${invoice.id}`
`invoice.paid: customer product not found for invoice ${invoice.id}`,
);
}
return;
@@ -217,10 +216,10 @@ export const handleInvoicePaid = async ({
});
if (!updated) {
let invoiceItems = await getInvoiceItems({
const invoiceItems = await getInvoiceItems({
stripeInvoice: invoice,
prices: activeCusProducts.flatMap((p) =>
p.customer_prices.map((cpr: FullCustomerPrice) => cpr.price)
p.customer_prices.map((cpr: FullCustomerPrice) => cpr.price),
),
logger,
});
@@ -230,13 +229,13 @@ export const handleInvoicePaid = async ({
try {
cusProducts = activeCusProducts.filter((cp) =>
invoiceLines.some((l) =>
lineItemInCusProduct({ cusProduct: cp, lineItem: l })
)
lineItemInCusProduct({ cusProduct: cp, lineItem: l }),
),
);
console.log(
"Invoice paid, filtered cus products:",
cusProducts.map((cp) => `${cp.product.name} - ${cp.product.id}`)
cusProducts.map((cp) => `${cp.product.name} - ${cp.product.id}`),
);
if (cusProducts.length == 0) {
@@ -248,7 +247,7 @@ export const handleInvoicePaid = async ({
}
const internalEntityId = new Set(
cusProducts.map((cp) => cp.internal_entity_id)
cusProducts.map((cp) => cp.internal_entity_id),
);
await InvoiceService.createInvoiceFromStripe({

View File

@@ -1,24 +1,24 @@
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,
@@ -39,8 +39,8 @@ export const handleInvoicePaidDiscount = async ({
return;
}
let stripeCus = await stripeCli.customers.retrieve(
expandedInvoice.customer as string
const stripeCus = await stripeCli.customers.retrieve(
expandedInvoice.customer as string,
);
try {
@@ -71,8 +71,10 @@ export const handleInvoicePaidDiscount = async ({
env,
});
let shouldRollover =
autumnReward && autumnReward.type == RewardType.InvoiceCredits;
const shouldRollover =
autumnReward &&
(autumnReward.type === RewardType.InvoiceCredits ||
autumnReward.type === RewardType.FreeProduct);
if (!shouldRollover) {
continue;
@@ -87,28 +89,28 @@ export const handleInvoicePaidDiscount = async ({
const curAmount = discount.coupon.amount_off;
const amountUsed = totalDiscountAmounts?.find(
(item) => item.discount === discount.id
(item) => item.discount === discount.id,
)?.amount;
const newAmount = new Decimal(curAmount!).sub(amountUsed!).toNumber();
let curExpiresAt = curCoupon.metadata?.expires_at
const curExpiresAt = curCoupon.metadata?.expires_at
? Number(curCoupon.metadata.expires_at)
: null;
let discountFinished = newAmount <= 0;
const discountFinished = newAmount <= 0;
let now = await getStripeNow({
const now = await getStripeNow({
stripeCli,
stripeCus: stripeCus as Stripe.Customer,
});
let expired = curExpiresAt && curExpiresAt < now;
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}`
`Coupon ${couponId}, stripeCus: ${stripeCus.id}: credits used up or expired. discountFinished: ${discountFinished}, expired: ${expired}`,
);
if (subId) {
@@ -125,13 +127,13 @@ export const handleInvoicePaidDiscount = async ({
}
logger.info(
`Coupon ${couponId}, stripeCus: ${stripeCus.id}, updating amount from ${curAmount} to ${newAmount}`
`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) {
const discountConfig = autumnReward?.discount_config;
if (discountConfig?.duration_type === CouponDurationType.Months) {
expiresAt = addMonths(new Date(), discountConfig.duration_value)
.getTime()
.toString();
@@ -160,7 +162,7 @@ export const handleInvoicePaidDiscount = async ({
`/v1/customers/${expandedInvoice.customer}`,
{
coupon: newCoupon.id,
}
},
);
await stripeCli.coupons.del(newCoupon.id);

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,
});
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",
});
}
});
rewardRouter.get("/:id", handleGetCoupon);
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,6 +81,7 @@ export const createStripeSub2 = async ({
discounts,
expand: ["latest_invoice"],
...{
trial_settings:
freeTrial && !freeTrial.card_required
? {
@@ -88,6 +90,21 @@ export const createStripeSub2 = async ({
},
}
: 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,18 +1,21 @@
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,
@@ -49,7 +52,7 @@ export const handleAddProduct = async ({
const batchInsert = [];
const { mergeCusProduct, mergeSub } = await getMergeCusProduct({
const { mergeSub } = await getMergeCusProduct({
attachParams,
config: config || defaultConfig,
products,
@@ -59,10 +62,10 @@ export const handleAddProduct = async ({
// throw new Error("test");
for (const product of products) {
let curCusProduct = attachParamsToCurCusProduct({ attachParams });
let anchorToUnix = undefined;
const curCusProduct = attachParamsToCurCusProduct({ attachParams });
let anchorToUnix;
if (curCusProduct && config?.branch == AttachBranch.NewVersion) {
if (curCusProduct && config?.branch === AttachBranch.NewVersion) {
anchorToUnix = curCusProduct.created_at;
}
@@ -81,7 +84,7 @@ export const handleAddProduct = async ({
carryExistingUsages: config?.carryUsage || false,
anchorToUnix,
logger,
})
}),
);
}
await Promise.all(batchInsert);
@@ -89,7 +92,7 @@ export const handleAddProduct = async ({
logger.info("Successfully created full cus product");
if (res) {
let apiVersion = attachParams.org.api_version || APIVersion.v1;
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) {
@@ -100,7 +103,7 @@ export const handleAddProduct = async ({
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({

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) {
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;
}) => {
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,22 +1,23 @@
import Stripe from "stripe";
import { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js";
import { AttachConfig, ProrationBehavior } from "@autumn/shared";
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 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";
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 { sanitizeSubItems } from "@/external/stripe/stripeSubUtils/getStripeSubItems.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";
export const updateStripeSub2 = async ({
req,
@@ -35,10 +36,15 @@ export const updateStripeSub2 = async ({
}) => {
const { db, logger } = req;
const { stripeCli, customer, org, paymentMethod } = attachParams;
const { stripeCli, customer, org, paymentMethod, rewardTrial } = attachParams;
const { invoiceOnly, proration } = config;
if (!invoiceOnly && !attachParams.fromCancel && nullish(paymentMethod)) {
if (
!invoiceOnly &&
!attachParams.fromCancel &&
!rewardTrial &&
nullish(paymentMethod)
) {
throw new RecaseError({
message: "Payment method is required",
code: "payment_method_required",
@@ -51,9 +57,11 @@ export const updateStripeSub2 = async ({
});
}
let trialEnd =
const trialEnd =
config.disableTrial || config.carryTrial
? undefined
: rewardTrial?.duration_value
? rewardTrialToStripeTimestamp({ rewardTrial, now: attachParams.now })
: freeTrialToStripeTimestamp({
freeTrial: attachParams.freeTrial,
now: attachParams.now,
@@ -61,10 +69,10 @@ export const updateStripeSub2 = async ({
// 1. Update subscription
let updatedSub = await stripeCli.subscriptions.update(curSub.id, {
const updatedSub = await stripeCli.subscriptions.update(curSub.id, {
items: sanitizeSubItems(itemSet.subItems),
proration_behavior:
proration == ProrationBehavior.None
proration === ProrationBehavior.None
? "none"
: fromCreate
? "always_invoice"
@@ -85,7 +93,7 @@ export const updateStripeSub2 = async ({
await SubService.updateFromStripe({ db, stripeSub: updatedSub });
if (proration == ProrationBehavior.None) {
if (proration === ProrationBehavior.None) {
return {
updatedSub,
latestInvoice: null,
@@ -102,7 +110,7 @@ export const updateStripeSub2 = async ({
const { curMainProduct } = attachParamToCusProducts({ attachParams });
// 2. Create prorations for single use items
let { invoiceItems, cusEntIds } = await createUsageInvoiceItems({
const { invoiceItems, cusEntIds } = await createUsageInvoiceItems({
db,
attachParams,
cusProduct: curMainProduct!,

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,
});
// 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,
// 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;
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
@@ -67,7 +70,7 @@ export const getAttachFunction = async ({
}
// 2. Upgrade scenarios
let updateScenarios = [
const updateScenarios = [
AttachBranch.NewVersion,
AttachBranch.SameCustom,
AttachBranch.SameCustomEnts,
@@ -89,7 +92,7 @@ export const getAttachFunction = async ({
// 4. Prepaid scenarios
if (branch == AttachBranch.UpdatePrepaidQuantity) {
let curSameProduct = attachParamsToCurCusProduct({ attachParams });
const curSameProduct = attachParamsToCurCusProduct({ attachParams });
if (curSameProduct?.free_trial) {
attachParams.freeTrial = curSameProduct.free_trial;
}
@@ -144,13 +147,13 @@ export const runAttachFunction = async ({
logger.info(`--------------------------------`);
logger.info(
`ATTACHING ${productIdsStr} to ${customer.name} (${customer.id || customer.email}), org: ${org.slug}\n`
`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)}`
`Branch: ${chalk.yellow(branch)}, Function: ${chalk.yellow(attachFunction)}`,
);
if (curMainProduct) {
@@ -161,11 +164,11 @@ export const runAttachFunction = async ({
}
if (curScheduledProduct) {
logger.info(
`→ Current Scheduled Product: ${curScheduledProduct.product.id}`
`→ Current Scheduled Product: ${curScheduledProduct.product.id}`,
);
}
if (attachFunction == AttachFunction.OneOff) {
if (attachFunction === AttachFunction.OneOff) {
return await handleOneOffFunction({
req,
res,
@@ -174,7 +177,7 @@ export const runAttachFunction = async ({
});
}
if (attachFunction == AttachFunction.Renew) {
if (attachFunction === AttachFunction.Renew) {
return await handleRenewProduct({
req,
res,
@@ -184,7 +187,7 @@ export const runAttachFunction = async ({
}
// 2. If main is trial, cancel it...
if (branch == AttachBranch.MainIsTrial) {
if (branch === AttachBranch.MainIsTrial) {
await CusProductService.update({
db,
cusProductId: curMainProduct!.id,
@@ -205,7 +208,7 @@ export const runAttachFunction = async ({
}
}
if (attachFunction == AttachFunction.MultiAttach) {
if (attachFunction === AttachFunction.MultiAttach) {
return await handleMultiAttachFlow({
req,
res,
@@ -216,7 +219,7 @@ export const runAttachFunction = async ({
});
}
if (attachFunction == AttachFunction.CreateCheckout) {
if (attachFunction === AttachFunction.CreateCheckout) {
if (config.invoiceCheckout) {
return await handleCreateInvoiceCheckout({
req,
@@ -235,7 +238,7 @@ export const runAttachFunction = async ({
});
}
if (attachFunction == AttachFunction.AddProduct) {
if (attachFunction === AttachFunction.AddProduct) {
return await handleAddProduct({
req,
res,
@@ -245,7 +248,7 @@ export const runAttachFunction = async ({
});
}
if (attachFunction == AttachFunction.ScheduleProduct) {
if (attachFunction === AttachFunction.ScheduleProduct) {
return await handleScheduleFunction2({
req,
res,
@@ -255,8 +258,8 @@ export const runAttachFunction = async ({
}
if (
attachFunction == AttachFunction.UpgradeDiffInterval ||
attachFunction == AttachFunction.UpgradeSameInterval
attachFunction === AttachFunction.UpgradeDiffInterval ||
attachFunction === AttachFunction.UpgradeSameInterval
) {
return await handleUpgradeFlow({
req,
@@ -267,7 +270,7 @@ export const runAttachFunction = async ({
});
}
if (attachFunction == AttachFunction.UpdatePrepaidQuantity) {
if (attachFunction === AttachFunction.UpdatePrepaidQuantity) {
return await handleUpdateQuantityFunction({
req,
res,

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}`);
// 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,17 +1,11 @@
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,
@@ -55,7 +49,7 @@ export const buildInvoiceMemo = async ({
console.log(
"Items: %s",
items.map((i) => i.feature_id)
items.map((i) => i.feature_id),
);
for (const item of items) {
@@ -68,7 +62,7 @@ export const buildInvoiceMemo = async ({
console.log(
"Display for item %s: %s",
item.feature_id,
display?.primary_text
display?.primary_text,
);
if (display?.primary_text) itemsToDisplay.push(display.primary_text);
}
@@ -118,13 +112,13 @@ export const buildInvoiceMemoFromEntitlements = async ({
});
itemsToDisplay.push(
`- ${display?.primary_text}${display?.secondary_text ? ` ${display?.secondary_text}` : ""}`
`- ${display?.primary_text}${display?.secondary_text ? ` ${display?.secondary_text}` : ""}`,
);
}
let memo = itemsToDisplay.join("\n");
if (memo.length > 490) {
memo = memo.slice(0, 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,7 +44,6 @@ const deleteStripeWebhooks = async ({
org: Organization;
logger: any;
}) => {
if (org.stripe_config) {
try {
await deleteStripeWebhook({
org: org,
@@ -60,7 +59,6 @@ const deleteStripeWebhooks = async ({
`Failed to delete stripe webhooks for ${org.id}, ${org.slug}. ${error.message})`
);
}
}
};
export const handleDeleteOrg = async (req: ExtendedRequest, res: Response) => {

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,18 +1,19 @@
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,
@@ -28,7 +29,7 @@ export const validateOneOffTrial = async ({
statusCode: 400,
});
}
}
};
export const validateAndInitFreeTrial = ({
freeTrial,
@@ -80,8 +81,8 @@ export const freeTrialToStripeTimestamp = ({
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) {
@@ -104,6 +105,21 @@ export const freeTrialToStripeTimestamp = ({
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,

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