Merge branch 'staging' into feat/reward-migrations
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -105,3 +105,6 @@ supabase/
|
||||
|
||||
migration.sh
|
||||
stat.sh
|
||||
|
||||
CLAUDE.md
|
||||
interview
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -15,7 +15,10 @@
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"recommended": true
|
||||
"recommended": true,
|
||||
"complexity": {
|
||||
"noStaticOnlyClass": "off"
|
||||
}
|
||||
}
|
||||
},
|
||||
"javascript": {
|
||||
|
||||
@@ -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
15047
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -26,6 +26,9 @@
|
||||
"noExportsInTest": "off",
|
||||
"noExplicitAny": "off",
|
||||
"noImplicitAnyLet": "off"
|
||||
},
|
||||
"complexity": {
|
||||
"noStaticOnlyClass": "off"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -7,16 +7,13 @@ source "$(dirname "$0")/config.sh"
|
||||
if [[ "$1" == *"setup"* ]]; then
|
||||
MOCHA_PARALLEL=true $MOCHA_SETUP
|
||||
fi
|
||||
# $MOCHA_CMD 'tests/advanced/multiFeature/*.ts'
|
||||
# $MOCHA_CMD 'tests/advanced/referrals/*.ts' 'tests/advanced/coupons/*.ts'
|
||||
|
||||
$MOCHA_CMD 'tests/advanced/multiFeature/*.ts' \
|
||||
'tests/advanced/coupons/*.ts' \
|
||||
'tests/attach/updateQuantity/*.ts' \
|
||||
'tests/advanced/referrals/*.ts' \
|
||||
'tests/advanced/rollovers/*.ts' \
|
||||
'tests/advanced/customInterval/*.ts'
|
||||
|
||||
$MOCHA_CMD 'tests/advanced/referrals/*.ts' \
|
||||
'tests/advanced/referrals/paid/*.ts' \
|
||||
'tests/advanced/rollovers/*.ts' \
|
||||
'tests/advanced/customInterval/*.ts'
|
||||
|
||||
|
||||
@@ -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
67
server/src/check2.ts
Normal 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);
|
||||
});
|
||||
862
server/src/external/autumn/autumnCli.ts
vendored
862
server/src/external/autumn/autumnCli.ts
vendored
@@ -1,497 +1,497 @@
|
||||
import dotenv from "dotenv";
|
||||
|
||||
dotenv.config();
|
||||
import { toSnakeCase } from "@/utils/genUtils.js";
|
||||
|
||||
import {
|
||||
APIVersion,
|
||||
CreateEntity,
|
||||
CreateRewardProgram,
|
||||
CusExpand,
|
||||
EntityExpand,
|
||||
ErrCode,
|
||||
Invoice,
|
||||
OrgConfig,
|
||||
type APIVersion,
|
||||
type AttachBody,
|
||||
type CreateEntity,
|
||||
type CreateRewardProgram,
|
||||
CusExpand,
|
||||
EntityExpand,
|
||||
ErrCode,
|
||||
type OrgConfig,
|
||||
type RewardRedemption,
|
||||
} from "@autumn/shared";
|
||||
import {
|
||||
CancelParams,
|
||||
CheckoutParams,
|
||||
CheckoutResult,
|
||||
CheckParams,
|
||||
CheckResult,
|
||||
Customer,
|
||||
TrackParams,
|
||||
TransferProductParams,
|
||||
UsageParams,
|
||||
import type {
|
||||
CancelParams,
|
||||
CheckoutParams,
|
||||
CheckoutResult,
|
||||
CheckParams,
|
||||
CheckResult,
|
||||
Customer,
|
||||
TrackParams,
|
||||
UsageParams,
|
||||
} from "autumn-js";
|
||||
import { AttachBody } from "@autumn/shared";
|
||||
|
||||
export default class AutumnError extends Error {
|
||||
message: string;
|
||||
code: string;
|
||||
message: string;
|
||||
code: string;
|
||||
|
||||
constructor({ message, code }: { message: string; code: string }) {
|
||||
super(message);
|
||||
this.message = message;
|
||||
this.code = code;
|
||||
}
|
||||
constructor({ message, code }: { message: string; code: string }) {
|
||||
super(message);
|
||||
this.message = message;
|
||||
this.code = code;
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return `${this.message} (code: ${this.code})`;
|
||||
}
|
||||
toString(): string {
|
||||
return `${this.message} (code: ${this.code})`;
|
||||
}
|
||||
}
|
||||
|
||||
export class AutumnInt {
|
||||
private apiKey: string;
|
||||
public headers: Record<string, string>;
|
||||
public baseUrl: string;
|
||||
private apiKey: string;
|
||||
public headers: Record<string, string>;
|
||||
public baseUrl: string;
|
||||
|
||||
constructor({
|
||||
apiKey,
|
||||
secretKey,
|
||||
baseUrl,
|
||||
version,
|
||||
orgConfig,
|
||||
}: {
|
||||
apiKey?: string;
|
||||
secretKey?: string;
|
||||
baseUrl?: string;
|
||||
version?: string | APIVersion;
|
||||
orgConfig?: Partial<OrgConfig>;
|
||||
} = {}) {
|
||||
// this.apiKey = apiKey || process.env.AUTUMN_API_KEY || "";
|
||||
this.apiKey =
|
||||
apiKey || secretKey || process.env.UNIT_TEST_AUTUMN_SECRET_KEY || "";
|
||||
constructor({
|
||||
apiKey,
|
||||
secretKey,
|
||||
baseUrl,
|
||||
version,
|
||||
orgConfig,
|
||||
}: {
|
||||
apiKey?: string;
|
||||
secretKey?: string;
|
||||
baseUrl?: string;
|
||||
version?: string | APIVersion;
|
||||
orgConfig?: Partial<OrgConfig>;
|
||||
} = {}) {
|
||||
// this.apiKey = apiKey || process.env.AUTUMN_API_KEY || "";
|
||||
this.apiKey =
|
||||
apiKey || secretKey || process.env.UNIT_TEST_AUTUMN_SECRET_KEY || "";
|
||||
|
||||
this.headers = {
|
||||
Authorization: `Bearer ${this.apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
this.headers = {
|
||||
Authorization: `Bearer ${this.apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
|
||||
if (version) {
|
||||
this.headers["x-api-version"] = version.toString();
|
||||
}
|
||||
if (version) {
|
||||
this.headers["x-api-version"] = version.toString();
|
||||
}
|
||||
|
||||
if (orgConfig) {
|
||||
this.headers["org-config"] = JSON.stringify(orgConfig);
|
||||
}
|
||||
if (orgConfig) {
|
||||
this.headers["org-config"] = JSON.stringify(orgConfig);
|
||||
}
|
||||
|
||||
this.baseUrl = baseUrl || "http://localhost:8080/v1";
|
||||
}
|
||||
this.baseUrl = baseUrl || "http://localhost:8080/v1";
|
||||
}
|
||||
|
||||
async get(path: string) {
|
||||
const response = await fetch(`${this.baseUrl}${path}`, {
|
||||
headers: this.headers,
|
||||
});
|
||||
return response.json();
|
||||
}
|
||||
async get(path: string) {
|
||||
const response = await fetch(`${this.baseUrl}${path}`, {
|
||||
headers: this.headers,
|
||||
});
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async post(path: string, body: any) {
|
||||
const response = await fetch(`${this.baseUrl}${path}`, {
|
||||
method: "POST",
|
||||
headers: this.headers,
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
async post(path: string, body: any) {
|
||||
const response = await fetch(`${this.baseUrl}${path}`, {
|
||||
method: "POST",
|
||||
headers: this.headers,
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (response.status != 200) {
|
||||
let error: any;
|
||||
try {
|
||||
error = await response.json();
|
||||
} catch (error) {
|
||||
throw new AutumnError({
|
||||
message: "Failed to parse Autumn API error response",
|
||||
code: ErrCode.InternalError,
|
||||
});
|
||||
}
|
||||
if (response.status != 200) {
|
||||
let error: any;
|
||||
try {
|
||||
error = await response.json();
|
||||
} catch (error) {
|
||||
throw new AutumnError({
|
||||
message: "Failed to parse Autumn API error response",
|
||||
code: ErrCode.InternalError,
|
||||
});
|
||||
}
|
||||
|
||||
throw new AutumnError({
|
||||
message: error.message,
|
||||
code: error.code,
|
||||
});
|
||||
}
|
||||
throw new AutumnError({
|
||||
message: error.message,
|
||||
code: error.code,
|
||||
});
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async delete(
|
||||
path: string,
|
||||
{
|
||||
deleteInStripe = false,
|
||||
}: {
|
||||
deleteInStripe?: boolean;
|
||||
} = {}
|
||||
) {
|
||||
const response = await fetch(
|
||||
`${this.baseUrl}${path}?${deleteInStripe ? "delete_in_stripe=true" : ""}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
headers: this.headers,
|
||||
}
|
||||
);
|
||||
async delete(
|
||||
path: string,
|
||||
{
|
||||
deleteInStripe = false,
|
||||
}: {
|
||||
deleteInStripe?: boolean;
|
||||
} = {},
|
||||
) {
|
||||
const response = await fetch(
|
||||
`${this.baseUrl}${path}?${deleteInStripe ? "delete_in_stripe=true" : ""}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
headers: this.headers,
|
||||
},
|
||||
);
|
||||
|
||||
if (response.status != 200) {
|
||||
let error: any;
|
||||
try {
|
||||
error = await response.json();
|
||||
} catch (error) {
|
||||
throw new AutumnError({
|
||||
message: "Failed to parse Autumn API error response",
|
||||
code: ErrCode.InternalError,
|
||||
});
|
||||
}
|
||||
if (response.status != 200) {
|
||||
let error: any;
|
||||
try {
|
||||
error = await response.json();
|
||||
} catch (error) {
|
||||
throw new AutumnError({
|
||||
message: "Failed to parse Autumn API error response",
|
||||
code: ErrCode.InternalError,
|
||||
});
|
||||
}
|
||||
|
||||
throw new AutumnError({
|
||||
message: error.message,
|
||||
code: error.code,
|
||||
});
|
||||
}
|
||||
throw new AutumnError({
|
||||
message: error.message,
|
||||
code: error.code,
|
||||
});
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async createCustomer({
|
||||
id,
|
||||
email,
|
||||
name,
|
||||
fingerprint,
|
||||
}: {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
fingerprint?: string;
|
||||
}) {
|
||||
const data = await this.post("/customers", {
|
||||
id,
|
||||
email,
|
||||
name,
|
||||
fingerprint,
|
||||
});
|
||||
async createCustomer({
|
||||
id,
|
||||
email,
|
||||
name,
|
||||
fingerprint,
|
||||
}: {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
fingerprint?: string;
|
||||
}) {
|
||||
const data = await this.post("/customers", {
|
||||
id,
|
||||
email,
|
||||
name,
|
||||
fingerprint,
|
||||
});
|
||||
|
||||
return data;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
async attach(params: AttachBody) {
|
||||
// const data = await this.post(`/attach`, {
|
||||
// customer_id: customerId,
|
||||
// product_id: productId,
|
||||
// options: toSnakeCase(options),
|
||||
// });
|
||||
const data = await this.post(`/attach`, params);
|
||||
async attach(params: AttachBody) {
|
||||
// const data = await this.post(`/attach`, {
|
||||
// customer_id: customerId,
|
||||
// product_id: productId,
|
||||
// options: toSnakeCase(options),
|
||||
// });
|
||||
const data = await this.post(`/attach`, params);
|
||||
|
||||
return data;
|
||||
}
|
||||
async checkout(
|
||||
params: CheckoutParams & { invoice?: boolean; force_checkout?: boolean }
|
||||
) {
|
||||
// const data = await this.post(`/attach`, {
|
||||
// customer_id: customerId,
|
||||
// product_id: productId,
|
||||
// options: toSnakeCase(options),
|
||||
// });
|
||||
const data = await this.post(`/checkout`, params);
|
||||
return data;
|
||||
}
|
||||
async checkout(
|
||||
params: CheckoutParams & { invoice?: boolean; force_checkout?: boolean },
|
||||
) {
|
||||
// const data = await this.post(`/attach`, {
|
||||
// customer_id: customerId,
|
||||
// product_id: productId,
|
||||
// options: toSnakeCase(options),
|
||||
// });
|
||||
const data = await this.post(`/checkout`, params);
|
||||
|
||||
return data as CheckoutResult;
|
||||
}
|
||||
async transfer(
|
||||
customerId: string,
|
||||
params: {
|
||||
from_entity_id?: string;
|
||||
to_entity_id: string;
|
||||
product_id: string;
|
||||
}
|
||||
) {
|
||||
const data = await this.post(`/customers/${customerId}/transfer`, params);
|
||||
return data as CheckoutResult;
|
||||
}
|
||||
async transfer(
|
||||
customerId: string,
|
||||
params: {
|
||||
from_entity_id?: string;
|
||||
to_entity_id: string;
|
||||
product_id: string;
|
||||
},
|
||||
) {
|
||||
const data = await this.post(`/customers/${customerId}/transfer`, params);
|
||||
|
||||
return data as CheckoutResult;
|
||||
}
|
||||
return data as CheckoutResult;
|
||||
}
|
||||
|
||||
async sendEvent({
|
||||
customerId,
|
||||
eventName,
|
||||
properties,
|
||||
customer_data,
|
||||
idempotency_key,
|
||||
}: {
|
||||
customerId: string;
|
||||
eventName: string;
|
||||
properties?: any;
|
||||
customer_data?: any;
|
||||
idempotency_key?: string;
|
||||
}) {
|
||||
const data = await this.post(`/events`, {
|
||||
customer_id: customerId,
|
||||
event_name: eventName,
|
||||
properties,
|
||||
customer_data,
|
||||
idempotency_key,
|
||||
});
|
||||
async sendEvent({
|
||||
customerId,
|
||||
eventName,
|
||||
properties,
|
||||
customer_data,
|
||||
idempotency_key,
|
||||
}: {
|
||||
customerId: string;
|
||||
eventName: string;
|
||||
properties?: any;
|
||||
customer_data?: any;
|
||||
idempotency_key?: string;
|
||||
}) {
|
||||
const data = await this.post(`/events`, {
|
||||
customer_id: customerId,
|
||||
event_name: eventName,
|
||||
properties,
|
||||
customer_data,
|
||||
idempotency_key,
|
||||
});
|
||||
|
||||
return data;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
async entitled({
|
||||
customerId,
|
||||
featureId,
|
||||
quantity,
|
||||
customer_data,
|
||||
}: {
|
||||
customerId: string;
|
||||
featureId: string;
|
||||
quantity?: number;
|
||||
customer_data?: any;
|
||||
}) {
|
||||
const data = await this.post(`/entitled`, {
|
||||
customer_id: customerId,
|
||||
feature_id: featureId,
|
||||
quantity,
|
||||
customer_data,
|
||||
});
|
||||
async entitled({
|
||||
customerId,
|
||||
featureId,
|
||||
quantity,
|
||||
customer_data,
|
||||
}: {
|
||||
customerId: string;
|
||||
featureId: string;
|
||||
quantity?: number;
|
||||
customer_data?: any;
|
||||
}) {
|
||||
const data = await this.post(`/entitled`, {
|
||||
customer_id: customerId,
|
||||
feature_id: featureId,
|
||||
quantity,
|
||||
customer_data,
|
||||
});
|
||||
|
||||
return data;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
customers = {
|
||||
get: async (
|
||||
customerId: string,
|
||||
params?: {
|
||||
expand?: CusExpand[];
|
||||
}
|
||||
): Promise<
|
||||
Customer & {
|
||||
invoices: any[];
|
||||
}
|
||||
> => {
|
||||
const queryParams = new URLSearchParams();
|
||||
const defaultParams = {
|
||||
expand: [CusExpand.Invoices],
|
||||
};
|
||||
customers = {
|
||||
get: async (
|
||||
customerId: string,
|
||||
params?: {
|
||||
expand?: CusExpand[];
|
||||
},
|
||||
): Promise<
|
||||
Customer & {
|
||||
invoices: any[];
|
||||
}
|
||||
> => {
|
||||
const queryParams = new URLSearchParams();
|
||||
const defaultParams = {
|
||||
expand: [CusExpand.Invoices],
|
||||
};
|
||||
|
||||
const finalParams = { ...defaultParams, ...params };
|
||||
if (finalParams.expand) {
|
||||
queryParams.append("expand", finalParams.expand.join(","));
|
||||
}
|
||||
const finalParams = { ...defaultParams, ...params };
|
||||
if (finalParams.expand) {
|
||||
queryParams.append("expand", finalParams.expand.join(","));
|
||||
}
|
||||
|
||||
const data = await this.get(
|
||||
`/customers/${customerId}?${queryParams.toString()}`
|
||||
);
|
||||
return data;
|
||||
},
|
||||
const data = await this.get(
|
||||
`/customers/${customerId}?${queryParams.toString()}`,
|
||||
);
|
||||
return data;
|
||||
},
|
||||
|
||||
create: async (customer: { id: string; email: string; name?: string }) => {
|
||||
const data = await this.post(`/customers?with_autumn_id=true`, customer);
|
||||
return data;
|
||||
},
|
||||
delete: async (
|
||||
customerId: string,
|
||||
{
|
||||
deleteInStripe = false,
|
||||
}: {
|
||||
deleteInStripe?: boolean;
|
||||
} = {}
|
||||
) => {
|
||||
const data = await this.delete(`/customers/${customerId}`, {
|
||||
deleteInStripe,
|
||||
});
|
||||
return data;
|
||||
},
|
||||
};
|
||||
create: async (customer: { id: string; email: string; name?: string }) => {
|
||||
const data = await this.post(`/customers?with_autumn_id=true`, customer);
|
||||
return data;
|
||||
},
|
||||
delete: async (
|
||||
customerId: string,
|
||||
{
|
||||
deleteInStripe = false,
|
||||
}: {
|
||||
deleteInStripe?: boolean;
|
||||
} = {},
|
||||
) => {
|
||||
const data = await this.delete(`/customers/${customerId}`, {
|
||||
deleteInStripe,
|
||||
});
|
||||
return data;
|
||||
},
|
||||
};
|
||||
|
||||
entities = {
|
||||
get: async (customerId: string, entityId: string) => {
|
||||
const data = await this.get(
|
||||
`/customers/${customerId}/entities/${entityId}?expand=${EntityExpand.Invoices}`
|
||||
);
|
||||
return data;
|
||||
},
|
||||
entities = {
|
||||
get: async (customerId: string, entityId: string) => {
|
||||
const data = await this.get(
|
||||
`/customers/${customerId}/entities/${entityId}?expand=${EntityExpand.Invoices}`,
|
||||
);
|
||||
return data;
|
||||
},
|
||||
|
||||
create: async (
|
||||
customerId: string,
|
||||
entity: CreateEntity | CreateEntity[]
|
||||
) => {
|
||||
// let entities = Array.isArray(entity) ? entity : [entity];
|
||||
const data = await this.post(
|
||||
`/customers/${customerId}/entities?with_autumn_id=true`,
|
||||
entity
|
||||
);
|
||||
create: async (
|
||||
customerId: string,
|
||||
entity: CreateEntity | CreateEntity[],
|
||||
) => {
|
||||
// let entities = Array.isArray(entity) ? entity : [entity];
|
||||
const data = await this.post(
|
||||
`/customers/${customerId}/entities?with_autumn_id=true`,
|
||||
entity,
|
||||
);
|
||||
|
||||
return data;
|
||||
},
|
||||
return data;
|
||||
},
|
||||
|
||||
list: async (customerId: string) => {
|
||||
const data = await this.get(`/customers/${customerId}/entities`);
|
||||
return data;
|
||||
},
|
||||
list: async (customerId: string) => {
|
||||
const data = await this.get(`/customers/${customerId}/entities`);
|
||||
return data;
|
||||
},
|
||||
|
||||
delete: async (customerId: string, entityId: string) => {
|
||||
const data = await this.delete(
|
||||
`/customers/${customerId}/entities/${entityId}`
|
||||
);
|
||||
return data;
|
||||
},
|
||||
};
|
||||
delete: async (customerId: string, entityId: string) => {
|
||||
const data = await this.delete(
|
||||
`/customers/${customerId}/entities/${entityId}`,
|
||||
);
|
||||
return data;
|
||||
},
|
||||
};
|
||||
|
||||
products = {
|
||||
update: async (productId: string, product: any) => {
|
||||
// if (product.items && typeof product.items === "object") {
|
||||
// product.items = Object.values(product.items);
|
||||
// }
|
||||
const data = await this.post(`/products/${productId}`, product);
|
||||
return data;
|
||||
},
|
||||
products = {
|
||||
update: async (productId: string, product: any) => {
|
||||
// if (product.items && typeof product.items === "object") {
|
||||
// product.items = Object.values(product.items);
|
||||
// }
|
||||
const data = await this.post(`/products/${productId}`, product);
|
||||
return data;
|
||||
},
|
||||
|
||||
get: async (
|
||||
productId: string,
|
||||
{ v1Schema = false }: { v1Schema?: boolean } = {}
|
||||
) => {
|
||||
const data = await this.get(
|
||||
`/products/${productId}?${v1Schema ? "schemaVersion=1" : ""}`
|
||||
);
|
||||
return data;
|
||||
},
|
||||
get: async (
|
||||
productId: string,
|
||||
{ v1Schema = false }: { v1Schema?: boolean } = {},
|
||||
) => {
|
||||
const data = await this.get(
|
||||
`/products/${productId}?${v1Schema ? "schemaVersion=1" : ""}`,
|
||||
);
|
||||
return data;
|
||||
},
|
||||
|
||||
create: async (product: any) => {
|
||||
const data = await this.post(`/products`, product);
|
||||
return data;
|
||||
},
|
||||
create: async (product: any) => {
|
||||
const data = await this.post(`/products`, product);
|
||||
return data;
|
||||
},
|
||||
|
||||
delete: async (productId: string) => {
|
||||
const data = await this.delete(`/products/${productId}`);
|
||||
return data;
|
||||
},
|
||||
};
|
||||
delete: async (productId: string) => {
|
||||
const data = await this.delete(`/products/${productId}`);
|
||||
return data;
|
||||
},
|
||||
};
|
||||
|
||||
rewards = {
|
||||
get: async (rewardId: string) => {
|
||||
const data = await this.get(`/rewards/${rewardId}`);
|
||||
return data;
|
||||
},
|
||||
rewards = {
|
||||
get: async (rewardId: string) => {
|
||||
const data = await this.get(`/rewards/${rewardId}`);
|
||||
return data;
|
||||
},
|
||||
|
||||
create: async (reward: any) => {
|
||||
const data = await this.post(`/rewards?legacyStripe=true`, reward);
|
||||
return data;
|
||||
},
|
||||
create: async (reward: any) => {
|
||||
const data = await this.post(`/rewards?legacyStripe=true`, reward);
|
||||
return data;
|
||||
},
|
||||
|
||||
delete: async (rewardId: string) => {
|
||||
const data = await this.delete(`/rewards/${rewardId}`);
|
||||
return data;
|
||||
},
|
||||
};
|
||||
delete: async (rewardId: string) => {
|
||||
const data = await this.delete(`/rewards/${rewardId}`);
|
||||
return data;
|
||||
},
|
||||
};
|
||||
|
||||
rewardPrograms = {
|
||||
create: async (rewardProgram: CreateRewardProgram) => {
|
||||
const data = await this.post(`/reward_programs`, rewardProgram);
|
||||
return data;
|
||||
},
|
||||
};
|
||||
rewardPrograms = {
|
||||
create: async (rewardProgram: CreateRewardProgram) => {
|
||||
const data = await this.post(`/reward_programs`, rewardProgram);
|
||||
return data;
|
||||
},
|
||||
};
|
||||
|
||||
referrals = {
|
||||
createCode: async ({
|
||||
customerId,
|
||||
referralId,
|
||||
}: {
|
||||
customerId: string;
|
||||
referralId: string;
|
||||
}) => {
|
||||
const data = await this.post(`/referrals/code`, {
|
||||
customer_id: customerId,
|
||||
program_id: referralId,
|
||||
});
|
||||
return data;
|
||||
},
|
||||
redeem: async ({
|
||||
customerId,
|
||||
code,
|
||||
}: {
|
||||
customerId: string;
|
||||
code: string;
|
||||
}) => {
|
||||
const data = await this.post(`/referrals/redeem`, {
|
||||
customer_id: customerId,
|
||||
code,
|
||||
});
|
||||
return data;
|
||||
},
|
||||
};
|
||||
referrals = {
|
||||
createCode: async ({
|
||||
customerId,
|
||||
referralId,
|
||||
}: {
|
||||
customerId: string;
|
||||
referralId: string;
|
||||
}) => {
|
||||
const data = await this.post(`/referrals/code`, {
|
||||
customer_id: customerId,
|
||||
program_id: referralId,
|
||||
});
|
||||
return data;
|
||||
},
|
||||
redeem: async ({
|
||||
customerId,
|
||||
code,
|
||||
}: {
|
||||
customerId: string;
|
||||
code: string;
|
||||
}) => {
|
||||
const data = await this.post(`/referrals/redeem`, {
|
||||
customer_id: customerId,
|
||||
code,
|
||||
});
|
||||
return data;
|
||||
},
|
||||
};
|
||||
|
||||
redemptions = {
|
||||
get: async (redemptionId: string) => {
|
||||
const data = await this.get(`/redemptions/${redemptionId}`);
|
||||
return data;
|
||||
},
|
||||
};
|
||||
redemptions = {
|
||||
get: async (redemptionId: string) => {
|
||||
const data = await this.get(`/redemptions/${redemptionId}`);
|
||||
return data as RewardRedemption;
|
||||
},
|
||||
};
|
||||
|
||||
events = {
|
||||
send: async ({
|
||||
customerId,
|
||||
featureId,
|
||||
value,
|
||||
properties,
|
||||
}: {
|
||||
customerId: string;
|
||||
featureId: string;
|
||||
value: number;
|
||||
properties?: any;
|
||||
}) => {
|
||||
const data = await this.post(`/events`, {
|
||||
customer_id: customerId,
|
||||
feature_id: featureId,
|
||||
value,
|
||||
properties,
|
||||
});
|
||||
return data;
|
||||
},
|
||||
};
|
||||
events = {
|
||||
send: async ({
|
||||
customerId,
|
||||
featureId,
|
||||
value,
|
||||
properties,
|
||||
}: {
|
||||
customerId: string;
|
||||
featureId: string;
|
||||
value: number;
|
||||
properties?: any;
|
||||
}) => {
|
||||
const data = await this.post(`/events`, {
|
||||
customer_id: customerId,
|
||||
feature_id: featureId,
|
||||
value,
|
||||
properties,
|
||||
});
|
||||
return data;
|
||||
},
|
||||
};
|
||||
|
||||
stripe = {
|
||||
connect: async (params: {
|
||||
secret_key: string;
|
||||
success_url: string;
|
||||
default_currency: string;
|
||||
}) => {
|
||||
const data = await this.post(`/organization/stripe`, params);
|
||||
return data;
|
||||
},
|
||||
stripe = {
|
||||
connect: async (params: {
|
||||
secret_key: string;
|
||||
success_url: string;
|
||||
default_currency: string;
|
||||
}) => {
|
||||
const data = await this.post(`/organization/stripe`, params);
|
||||
return data;
|
||||
},
|
||||
|
||||
delete: async () => {
|
||||
const data = await this.delete(`/organization/stripe`);
|
||||
return data;
|
||||
},
|
||||
};
|
||||
delete: async () => {
|
||||
const data = await this.delete(`/organization/stripe`);
|
||||
return data;
|
||||
},
|
||||
};
|
||||
|
||||
track = async (params: TrackParams & { timestamp?: number }) => {
|
||||
const data = await this.post(`/track`, params);
|
||||
return data;
|
||||
};
|
||||
track = async (params: TrackParams & { timestamp?: number }) => {
|
||||
const data = await this.post(`/track`, params);
|
||||
return data;
|
||||
};
|
||||
|
||||
usage = async (params: UsageParams) => {
|
||||
const data = await this.post(`/usage`, params);
|
||||
return data;
|
||||
};
|
||||
usage = async (params: UsageParams) => {
|
||||
const data = await this.post(`/usage`, params);
|
||||
return data;
|
||||
};
|
||||
|
||||
check = async (params: CheckParams): Promise<CheckResult> => {
|
||||
const data = await this.post(`/check`, params);
|
||||
return data;
|
||||
};
|
||||
check = async (params: CheckParams): Promise<CheckResult> => {
|
||||
const data = await this.post(`/check`, params);
|
||||
return data;
|
||||
};
|
||||
|
||||
attachPreview = async (params: AttachBody) => {
|
||||
const data = await this.post(`/attach/preview`, params);
|
||||
return data;
|
||||
};
|
||||
attachPreview = async (params: AttachBody) => {
|
||||
const data = await this.post(`/attach/preview`, params);
|
||||
return data;
|
||||
};
|
||||
|
||||
cancel = async (params: CancelParams) => {
|
||||
const data = await this.post(`/cancel`, params);
|
||||
return data;
|
||||
};
|
||||
cancel = async (params: CancelParams) => {
|
||||
const data = await this.post(`/cancel`, params);
|
||||
return data;
|
||||
};
|
||||
|
||||
migrate = async (params: {
|
||||
from_product_id: string;
|
||||
to_product_id: string;
|
||||
from_version: number;
|
||||
to_version: number;
|
||||
}) => {
|
||||
const data = await this.post(`/migrations`, params);
|
||||
return data;
|
||||
};
|
||||
migrate = async (params: {
|
||||
from_product_id: string;
|
||||
to_product_id: string;
|
||||
from_version: number;
|
||||
to_version: number;
|
||||
}) => {
|
||||
const data = await this.post(`/migrations`, params);
|
||||
return data;
|
||||
};
|
||||
|
||||
initStripe = async () => {
|
||||
await this.post(`/products/all/init_stripe`, {});
|
||||
};
|
||||
initStripe = async () => {
|
||||
await this.post(`/products/all/init_stripe`, {});
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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) {
|
||||
|
||||
130
server/src/external/stripe/stripeCouponUtils/extendCouponDuration.ts
vendored
Normal file
130
server/src/external/stripe/stripeCouponUtils/extendCouponDuration.ts
vendored
Normal file
@@ -0,0 +1,130 @@
|
||||
// import Stripe from "stripe";
|
||||
|
||||
// /**
|
||||
// * Extends an existing coupon's duration by replacing it with a new coupon that has
|
||||
// * the remaining duration + additional months. This avoids concurrent stacking issues.
|
||||
// */
|
||||
// export const extendCouponDuration = async ({
|
||||
// stripeCli,
|
||||
// sub,
|
||||
// existingCouponId,
|
||||
// additionalMonths,
|
||||
// logger,
|
||||
// }: {
|
||||
// stripeCli: Stripe;
|
||||
// sub: Stripe.Subscription;
|
||||
// existingCouponId: string;
|
||||
// additionalMonths: number;
|
||||
// logger: any;
|
||||
// }): Promise<{
|
||||
// success: boolean;
|
||||
// newCouponId?: string;
|
||||
// error?: string;
|
||||
// }> => {
|
||||
// try {
|
||||
// logger.info(
|
||||
// `Extending coupon ${existingCouponId} by ${additionalMonths} months`
|
||||
// );
|
||||
|
||||
// // Get current subscription and coupon details
|
||||
// const existingCoupon = await stripeCli.coupons.retrieve(existingCouponId);
|
||||
// const currentDiscounts = (sub.discounts as Stripe.Discount[]) || [];
|
||||
// const existingDiscount = currentDiscounts.find((d: any) =>
|
||||
// d.coupon?.id.startsWith(existingCouponId)
|
||||
// );
|
||||
|
||||
// if (!existingDiscount) {
|
||||
// return {
|
||||
// success: false,
|
||||
// error: "Original coupon not found on subscription",
|
||||
// };
|
||||
// }
|
||||
|
||||
// // Calculate remaining months using the discount's actual start and end times
|
||||
// const originalDurationMonths = existingCoupon.duration_in_months || 0;
|
||||
|
||||
// // Use discount start and end times for accurate calculation
|
||||
// const discountStart = new Date(existingDiscount.start * 1000);
|
||||
// const discountEnd = existingDiscount.end
|
||||
// ? new Date(existingDiscount.end * 1000)
|
||||
// : null;
|
||||
// const now = new Date();
|
||||
|
||||
// let remainingMonths: number;
|
||||
|
||||
// if (discountEnd) {
|
||||
// // Calculate remaining time in months
|
||||
// const remainingTimeMs = Math.max(
|
||||
// 0,
|
||||
// discountEnd.getTime() - now.getTime()
|
||||
// );
|
||||
// remainingMonths = Math.ceil(remainingTimeMs / (30 * 24 * 60 * 60 * 1000));
|
||||
// } else {
|
||||
// // If no end date (shouldn't happen for repeating coupons), fall back to original duration
|
||||
// remainingMonths = originalDurationMonths;
|
||||
// }
|
||||
|
||||
// const totalNewDurationMonths = remainingMonths + additionalMonths;
|
||||
|
||||
// logger.info(
|
||||
// `Discount period: ${discountStart.toISOString()} to ${discountEnd?.toISOString() || "forever"}`
|
||||
// );
|
||||
// logger.info(
|
||||
// `Original: ${originalDurationMonths}m, Remaining: ${remainingMonths}m, Adding: ${additionalMonths}m, Total: ${totalNewDurationMonths}m`
|
||||
// );
|
||||
|
||||
// // Create a new coupon with the extended duration
|
||||
// const extendedCouponId = `${existingCouponId}_${Date.now()}`;
|
||||
// const couponCreateParams: Stripe.CouponCreateParams = {
|
||||
// id: extendedCouponId,
|
||||
// duration: "repeating",
|
||||
// duration_in_months: totalNewDurationMonths,
|
||||
// name: `Extended ${existingCoupon.name || "Coupon"}`,
|
||||
// // metadata: {
|
||||
// // original_coupon_id: existingCouponId,
|
||||
// // original_duration: originalDurationMonths.toString(),
|
||||
// // remaining_months: remainingMonths.toString(),
|
||||
// // additional_months: additionalMonths.toString(),
|
||||
// // total_duration: totalNewDurationMonths.toString(),
|
||||
// // extended_at: Date.now().toString(),
|
||||
// // },
|
||||
// };
|
||||
|
||||
// // Copy discount value from the existing coupon
|
||||
// if (existingCoupon.percent_off) {
|
||||
// couponCreateParams.percent_off = existingCoupon.percent_off;
|
||||
// } else if (existingCoupon.amount_off) {
|
||||
// couponCreateParams.amount_off = existingCoupon.amount_off;
|
||||
// if (existingCoupon.currency) {
|
||||
// couponCreateParams.currency = existingCoupon.currency;
|
||||
// }
|
||||
// }
|
||||
|
||||
// // Copy applies_to if it exists
|
||||
// if (existingCoupon.applies_to) {
|
||||
// couponCreateParams.applies_to = existingCoupon.applies_to;
|
||||
// }
|
||||
|
||||
// const extendedCoupon = await stripeCli.coupons.create(couponCreateParams);
|
||||
|
||||
// // Replace the existing coupon with the extended one
|
||||
// const otherDiscounts = currentDiscounts
|
||||
// .filter((d: any) => d.coupon?.id !== existingCouponId)
|
||||
// .map((d: Stripe.Discount) => ({ discount: d.id }));
|
||||
|
||||
// await stripeCli.subscriptions.update(sub.id, {
|
||||
// discounts: [...otherDiscounts, { coupon: extendedCoupon.id }],
|
||||
// });
|
||||
|
||||
// logger.info(
|
||||
// `Successfully extended coupon duration to ${totalNewDurationMonths} months. New coupon ID: ${extendedCouponId}`
|
||||
// );
|
||||
|
||||
// await stripeCli.coupons.del(existingCouponId);
|
||||
|
||||
// return { success: true, newCouponId: extendedCouponId };
|
||||
// } catch (error: any) {
|
||||
// logger.error(`Failed to extend coupon duration: ${error.message}`, error);
|
||||
// return { success: false, error: error.message };
|
||||
// }
|
||||
// };
|
||||
@@ -1,23 +1,48 @@
|
||||
import RecaseError from "@/utils/errorUtils.js";
|
||||
import {
|
||||
Reward,
|
||||
type AppEnv,
|
||||
CouponDurationType,
|
||||
ErrCode,
|
||||
Organization,
|
||||
Price,
|
||||
type FixedPriceConfig,
|
||||
type Organization,
|
||||
type Price,
|
||||
PriceType,
|
||||
Product,
|
||||
UsagePriceConfig,
|
||||
type Product,
|
||||
type Reward,
|
||||
RewardType,
|
||||
AppEnv,
|
||||
type UsagePriceConfig,
|
||||
} from "@autumn/shared";
|
||||
import { Stripe } from "stripe";
|
||||
import { pricesOnlyOneOff } from "@/internal/products/prices/priceUtils.js";
|
||||
import RecaseError from "@/utils/errorUtils.js";
|
||||
import { createStripeCli } from "../utils.js";
|
||||
|
||||
const couponToStripeDuration = (coupon: Reward) => {
|
||||
let discountConfig = coupon.discount_config;
|
||||
const couponToStripeDuration = ({
|
||||
coupon,
|
||||
isOneOffProduct = false,
|
||||
}: {
|
||||
coupon: Reward;
|
||||
isOneOffProduct: boolean;
|
||||
}) => {
|
||||
if (coupon.type === RewardType.FreeProduct) {
|
||||
// For one-off products, the coupon should apply once, not repeat
|
||||
if (isOneOffProduct) {
|
||||
return {
|
||||
duration: "once",
|
||||
};
|
||||
}
|
||||
console.log("--------------------------------");
|
||||
console.log("rewardName", coupon.name);
|
||||
console.log("coupon.free_product_config", coupon.free_product_config);
|
||||
console.log("isOneOffProduct", isOneOffProduct);
|
||||
console.log("--------------------------------");
|
||||
return {
|
||||
duration: "repeating",
|
||||
duration_in_months: coupon.free_product_config?.duration_value,
|
||||
};
|
||||
}
|
||||
|
||||
const discountConfig = coupon.discount_config;
|
||||
if (
|
||||
coupon.type == RewardType.InvoiceCredits &&
|
||||
coupon.type === RewardType.InvoiceCredits &&
|
||||
coupon.discount_config?.duration_type === CouponDurationType.Forever
|
||||
) {
|
||||
return {
|
||||
@@ -25,6 +50,13 @@ const couponToStripeDuration = (coupon: Reward) => {
|
||||
};
|
||||
}
|
||||
|
||||
// For one-off products, always use "once" duration regardless of config
|
||||
if (isOneOffProduct) {
|
||||
return {
|
||||
duration: "once",
|
||||
};
|
||||
}
|
||||
|
||||
switch (discountConfig!.duration_type) {
|
||||
case CouponDurationType.Forever:
|
||||
return {
|
||||
@@ -45,11 +77,28 @@ const couponToStripeDuration = (coupon: Reward) => {
|
||||
const couponToStripeValue = ({
|
||||
reward,
|
||||
org,
|
||||
prices,
|
||||
}: {
|
||||
reward: Reward;
|
||||
org: Organization;
|
||||
prices?: (Price & { product: Product })[];
|
||||
}) => {
|
||||
let discountConfig = reward.discount_config;
|
||||
if (reward.type === RewardType.FreeProduct) {
|
||||
const amountOff = Math.round(
|
||||
prices?.reduce(
|
||||
(acc, price) => acc + (price.config as FixedPriceConfig).amount,
|
||||
0
|
||||
) || 0
|
||||
);
|
||||
|
||||
console.log("amountOff in couponToStripeValue", amountOff);
|
||||
return {
|
||||
amount_off: Math.round(amountOff * 100),
|
||||
currency: org.default_currency || "usd",
|
||||
};
|
||||
}
|
||||
|
||||
const discountConfig = reward.discount_config;
|
||||
if (reward.type === RewardType.PercentageDiscount) {
|
||||
return {
|
||||
percent_off: discountConfig!.discount_value,
|
||||
@@ -80,7 +129,7 @@ export const createStripeCoupon = async ({
|
||||
logger: any;
|
||||
legacyVersion?: boolean;
|
||||
}) => {
|
||||
let discountConfig = reward.discount_config;
|
||||
const discountConfig = reward.discount_config;
|
||||
|
||||
const stripeCli = createStripeCli({
|
||||
org,
|
||||
@@ -90,9 +139,9 @@ export const createStripeCoupon = async ({
|
||||
|
||||
try {
|
||||
await stripeCli.coupons.del(reward.id);
|
||||
} catch (error) {}
|
||||
} catch (_) {}
|
||||
|
||||
let stripeProdIds = prices.map((price) => {
|
||||
const stripeProdIds = prices.map((price) => {
|
||||
if (price.config!.type === PriceType.Fixed) {
|
||||
return price.product.processor?.id;
|
||||
} else {
|
||||
@@ -116,26 +165,32 @@ export const createStripeCoupon = async ({
|
||||
promoCode.code
|
||||
);
|
||||
throw new RecaseError({
|
||||
message: `Promo code ${promoCode.code} already exists in Stripe`,
|
||||
message: `Promo code ${promoCode.code} (${stripePromoCode.id}) already exists in Stripe`,
|
||||
code: ErrCode.PromoCodeAlreadyExistsInStripe,
|
||||
});
|
||||
} catch (error) {}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
const stripeCoupon = await stripeCli.coupons.create({
|
||||
// id: reward.internal_id,
|
||||
id: reward.id,
|
||||
...(couponToStripeDuration(reward) as any),
|
||||
...(couponToStripeValue({ reward, org }) as any),
|
||||
...(couponToStripeDuration({
|
||||
coupon: reward,
|
||||
isOneOffProduct: pricesOnlyOneOff(prices),
|
||||
}) as any),
|
||||
...(couponToStripeValue({ reward, org, prices }) as any),
|
||||
name: reward.name,
|
||||
metadata: {
|
||||
autumn_internal_id: reward.internal_id,
|
||||
},
|
||||
applies_to: !discountConfig!.apply_to_all
|
||||
? {
|
||||
products: stripeProdIds,
|
||||
}
|
||||
: undefined,
|
||||
applies_to:
|
||||
reward.type === RewardType.FreeProduct
|
||||
? undefined
|
||||
: !discountConfig!.apply_to_all
|
||||
? {
|
||||
products: stripeProdIds,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
|
||||
// Create promo codes
|
||||
|
||||
520
server/src/external/stripe/stripeCusUtils.ts
vendored
520
server/src/external/stripe/stripeCusUtils.ts
vendored
@@ -1,332 +1,338 @@
|
||||
import { AppEnv, Customer, Organization, ProcessorType } from "@autumn/shared";
|
||||
import { Stripe } from "stripe";
|
||||
import { createStripeCli } from "./utils.js";
|
||||
import RecaseError from "@/utils/errorUtils.js";
|
||||
import { ErrCode } from "@autumn/shared";
|
||||
import {
|
||||
type AppEnv,
|
||||
type Customer,
|
||||
ErrCode,
|
||||
type Organization,
|
||||
ProcessorType,
|
||||
} from "@autumn/shared";
|
||||
import { StatusCodes } from "http-status-codes";
|
||||
import type { Stripe } from "stripe";
|
||||
import type { DrizzleCli } from "@/db/initDrizzle.js";
|
||||
import { CusService } from "@/internal/customers/CusService.js";
|
||||
import { DrizzleCli } from "@/db/initDrizzle.js";
|
||||
import RecaseError from "@/utils/errorUtils.js";
|
||||
import { createStripeCli } from "./utils.js";
|
||||
|
||||
export const getStripeCus = async ({
|
||||
stripeCli,
|
||||
stripeId,
|
||||
stripeCli,
|
||||
stripeId,
|
||||
}: {
|
||||
stripeCli: Stripe;
|
||||
stripeId: string;
|
||||
stripeCli: Stripe;
|
||||
stripeId: string;
|
||||
}) => {
|
||||
try {
|
||||
const stripeCus = await stripeCli.customers.retrieve(stripeId);
|
||||
return stripeCus as Stripe.Customer;
|
||||
} catch (error) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const stripeCus = await stripeCli.customers.retrieve(stripeId);
|
||||
return stripeCus as Stripe.Customer;
|
||||
} catch (error) {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
export const createStripeCusIfNotExists = async ({
|
||||
db,
|
||||
org,
|
||||
env,
|
||||
customer,
|
||||
logger,
|
||||
db,
|
||||
org,
|
||||
env,
|
||||
customer,
|
||||
logger,
|
||||
}: {
|
||||
db: DrizzleCli;
|
||||
org: Organization;
|
||||
env: AppEnv;
|
||||
customer: Customer;
|
||||
logger: any;
|
||||
db: DrizzleCli;
|
||||
org: Organization;
|
||||
env: AppEnv;
|
||||
customer: Customer;
|
||||
logger: any;
|
||||
}) => {
|
||||
let createNew = false;
|
||||
const stripeCli = createStripeCli({ org, env });
|
||||
if (!customer.processor || !customer.processor.id) {
|
||||
createNew = true;
|
||||
} else {
|
||||
try {
|
||||
let stripeCus = await stripeCli.customers.retrieve(
|
||||
customer.processor.id,
|
||||
{
|
||||
expand: ["test_clock", "invoice_settings.default_payment_method"],
|
||||
}
|
||||
);
|
||||
if (!stripeCus.deleted) {
|
||||
return stripeCus as Stripe.Customer;
|
||||
} else {
|
||||
createNew = true;
|
||||
}
|
||||
} catch (error) {
|
||||
createNew = true;
|
||||
}
|
||||
}
|
||||
let createNew = false;
|
||||
const stripeCli = createStripeCli({ org, env });
|
||||
if (!customer.processor || !customer.processor.id) {
|
||||
createNew = true;
|
||||
} else {
|
||||
try {
|
||||
const stripeCus = await stripeCli.customers.retrieve(
|
||||
customer.processor.id,
|
||||
{
|
||||
expand: ["test_clock", "invoice_settings.default_payment_method"],
|
||||
},
|
||||
);
|
||||
if (!stripeCus.deleted) {
|
||||
return stripeCus as Stripe.Customer;
|
||||
} else {
|
||||
createNew = true;
|
||||
}
|
||||
} catch (error) {
|
||||
createNew = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (createNew) {
|
||||
logger.info(`Creating new stripe customer for ${customer.id}`);
|
||||
const stripeCustomer = await createStripeCustomer({
|
||||
org,
|
||||
env,
|
||||
customer,
|
||||
});
|
||||
if (createNew) {
|
||||
logger.info(`Creating new stripe customer for ${customer.id}`);
|
||||
const stripeCustomer = await createStripeCustomer({
|
||||
org,
|
||||
env,
|
||||
customer,
|
||||
});
|
||||
|
||||
await CusService.update({
|
||||
db,
|
||||
internalCusId: customer.internal_id,
|
||||
update: {
|
||||
processor: {
|
||||
id: stripeCustomer.id,
|
||||
type: ProcessorType.Stripe,
|
||||
},
|
||||
},
|
||||
});
|
||||
await CusService.update({
|
||||
db,
|
||||
internalCusId: customer.internal_id,
|
||||
update: {
|
||||
processor: {
|
||||
id: stripeCustomer.id,
|
||||
type: ProcessorType.Stripe,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
customer.processor = {
|
||||
id: stripeCustomer.id,
|
||||
type: ProcessorType.Stripe,
|
||||
};
|
||||
customer.processor = {
|
||||
id: stripeCustomer.id,
|
||||
type: ProcessorType.Stripe,
|
||||
};
|
||||
|
||||
return stripeCustomer;
|
||||
}
|
||||
return stripeCustomer;
|
||||
}
|
||||
};
|
||||
|
||||
export const createStripeCustomer = async ({
|
||||
org,
|
||||
env,
|
||||
customer,
|
||||
testClockId,
|
||||
org,
|
||||
env,
|
||||
customer,
|
||||
testClockId,
|
||||
}: {
|
||||
org: Organization;
|
||||
env: AppEnv;
|
||||
customer: Customer;
|
||||
testClockId?: string;
|
||||
org: Organization;
|
||||
env: AppEnv;
|
||||
customer: Customer;
|
||||
testClockId?: string;
|
||||
}) => {
|
||||
const stripeCli = createStripeCli({ org, env });
|
||||
const stripeCli = createStripeCli({ org, env });
|
||||
|
||||
try {
|
||||
const stripeCustomer = await stripeCli.customers.create({
|
||||
name: customer.name || undefined,
|
||||
email: customer.email || undefined,
|
||||
metadata: {
|
||||
autumn_id: customer.id || null,
|
||||
autumn_internal_id: customer.internal_id,
|
||||
},
|
||||
test_clock: testClockId,
|
||||
});
|
||||
try {
|
||||
const stripeCustomer = await stripeCli.customers.create({
|
||||
name: customer.name || undefined,
|
||||
email: customer.email || undefined,
|
||||
metadata: {
|
||||
autumn_id: customer.id || null,
|
||||
autumn_internal_id: customer.internal_id,
|
||||
},
|
||||
test_clock: testClockId,
|
||||
});
|
||||
|
||||
return stripeCustomer;
|
||||
} catch (error: any) {
|
||||
throw new RecaseError({
|
||||
message: `Error creating customer in Stripe. ${error.message}`,
|
||||
code: ErrCode.StripeCreateCustomerFailed,
|
||||
statusCode: StatusCodes.INTERNAL_SERVER_ERROR,
|
||||
});
|
||||
}
|
||||
return stripeCustomer;
|
||||
} catch (error: any) {
|
||||
throw new RecaseError({
|
||||
message: `Error creating customer in Stripe. ${error.message}`,
|
||||
code: ErrCode.StripeCreateCustomerFailed,
|
||||
statusCode: StatusCodes.INTERNAL_SERVER_ERROR,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteStripeCustomer = async ({
|
||||
org,
|
||||
env,
|
||||
stripeId,
|
||||
org,
|
||||
env,
|
||||
stripeId,
|
||||
}: {
|
||||
org: Organization;
|
||||
env: AppEnv;
|
||||
stripeId: string;
|
||||
org: Organization;
|
||||
env: AppEnv;
|
||||
stripeId: string;
|
||||
}) => {
|
||||
const stripeCli = createStripeCli({ org, env });
|
||||
const stripeCli = createStripeCli({ org, env });
|
||||
|
||||
const stripeCustomer = await stripeCli.customers.del(stripeId);
|
||||
const stripeCustomer = await stripeCli.customers.del(stripeId);
|
||||
|
||||
return stripeCustomer;
|
||||
return stripeCustomer;
|
||||
};
|
||||
|
||||
export const listCusPaymentMethods = async ({
|
||||
stripeCli,
|
||||
stripeId,
|
||||
stripeCli,
|
||||
stripeId,
|
||||
}: {
|
||||
stripeCli: Stripe;
|
||||
stripeId: string;
|
||||
stripeCli: Stripe;
|
||||
stripeId: string;
|
||||
}) => {
|
||||
let res = await stripeCli.paymentMethods.list({
|
||||
customer: stripeId,
|
||||
});
|
||||
const res = await stripeCli.paymentMethods.list({
|
||||
customer: stripeId,
|
||||
});
|
||||
|
||||
const paymentMethods = res.data;
|
||||
paymentMethods.sort((a, b) => b.created - a.created);
|
||||
const paymentMethods = res.data;
|
||||
paymentMethods.sort((a, b) => b.created - a.created);
|
||||
|
||||
return paymentMethods;
|
||||
return paymentMethods;
|
||||
};
|
||||
|
||||
export const getCusPaymentMethod = async ({
|
||||
stripeCli,
|
||||
stripeId,
|
||||
errorIfNone = false,
|
||||
stripeCli,
|
||||
stripeId,
|
||||
errorIfNone = false,
|
||||
}: {
|
||||
stripeCli: Stripe;
|
||||
stripeId?: string;
|
||||
errorIfNone?: boolean;
|
||||
stripeCli: Stripe;
|
||||
stripeId?: string;
|
||||
errorIfNone?: boolean;
|
||||
}) => {
|
||||
if (!stripeId) {
|
||||
return null;
|
||||
}
|
||||
if (!stripeId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const stripeCustomer = (await stripeCli.customers.retrieve(
|
||||
stripeId
|
||||
)) as Stripe.Customer;
|
||||
const stripeCustomer = (await stripeCli.customers.retrieve(
|
||||
stripeId,
|
||||
)) as Stripe.Customer;
|
||||
|
||||
let paymentMethodId = stripeCustomer.invoice_settings?.default_payment_method;
|
||||
const paymentMethodId =
|
||||
stripeCustomer.invoice_settings?.default_payment_method;
|
||||
|
||||
if (!paymentMethodId) {
|
||||
let res = await stripeCli.paymentMethods.list({
|
||||
customer: stripeId,
|
||||
});
|
||||
if (!paymentMethodId) {
|
||||
const res = await stripeCli.paymentMethods.list({
|
||||
customer: stripeId,
|
||||
});
|
||||
|
||||
const paymentMethods = res.data;
|
||||
paymentMethods.sort((a, b) => b.created - a.created);
|
||||
const paymentMethods = res.data;
|
||||
paymentMethods.sort((a, b) => b.created - a.created);
|
||||
|
||||
if (res.data.length === 0) {
|
||||
if (errorIfNone) {
|
||||
throw new RecaseError({
|
||||
code: ErrCode.StripeGetPaymentMethodFailed,
|
||||
message: `No payment method found for customer ${stripeId}`,
|
||||
statusCode: 500,
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (res.data.length === 0) {
|
||||
if (errorIfNone) {
|
||||
throw new RecaseError({
|
||||
code: ErrCode.StripeGetPaymentMethodFailed,
|
||||
message: `No payment method found for customer ${stripeId}`,
|
||||
statusCode: 500,
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return paymentMethods[0];
|
||||
} else {
|
||||
const paymentMethod = await stripeCli.paymentMethods.retrieve(
|
||||
paymentMethodId as string
|
||||
);
|
||||
return paymentMethod;
|
||||
}
|
||||
return paymentMethods[0];
|
||||
} else {
|
||||
const paymentMethod = await stripeCli.paymentMethods.retrieve(
|
||||
paymentMethodId as string,
|
||||
);
|
||||
return paymentMethod;
|
||||
}
|
||||
};
|
||||
|
||||
// 2. Create a payment method and attach to customer
|
||||
export const attachPmToCus = async ({
|
||||
db,
|
||||
customer,
|
||||
org,
|
||||
env,
|
||||
willFail = false,
|
||||
testClockId,
|
||||
db,
|
||||
customer,
|
||||
org,
|
||||
env,
|
||||
willFail = false,
|
||||
testClockId,
|
||||
}: {
|
||||
db: DrizzleCli;
|
||||
customer: Customer;
|
||||
org: Organization;
|
||||
env: AppEnv;
|
||||
willFail?: boolean;
|
||||
testClockId?: string;
|
||||
db: DrizzleCli;
|
||||
customer: Customer;
|
||||
org: Organization;
|
||||
env: AppEnv;
|
||||
willFail?: boolean;
|
||||
testClockId?: string;
|
||||
}) => {
|
||||
// 1. Create stripe customer if not exists
|
||||
// 1. Create stripe customer if not exists
|
||||
|
||||
let stripeCusId = customer.processor?.id;
|
||||
if (!stripeCusId) {
|
||||
const stripeCustomer = await createStripeCustomer({
|
||||
org,
|
||||
env,
|
||||
customer,
|
||||
testClockId,
|
||||
});
|
||||
let stripeCusId = customer.processor?.id;
|
||||
if (!stripeCusId) {
|
||||
const stripeCustomer = await createStripeCustomer({
|
||||
org,
|
||||
env,
|
||||
customer,
|
||||
testClockId,
|
||||
});
|
||||
|
||||
await CusService.update({
|
||||
db,
|
||||
internalCusId: customer.internal_id,
|
||||
update: {
|
||||
processor: {
|
||||
id: stripeCustomer.id,
|
||||
type: ProcessorType.Stripe,
|
||||
},
|
||||
},
|
||||
});
|
||||
await CusService.update({
|
||||
db,
|
||||
internalCusId: customer.internal_id,
|
||||
update: {
|
||||
processor: {
|
||||
id: stripeCustomer.id,
|
||||
type: ProcessorType.Stripe,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
stripeCusId = stripeCustomer.id;
|
||||
customer.processor = {
|
||||
id: stripeCustomer.id,
|
||||
type: "stripe",
|
||||
};
|
||||
}
|
||||
stripeCusId = stripeCustomer.id;
|
||||
customer.processor = {
|
||||
id: stripeCustomer.id,
|
||||
type: "stripe",
|
||||
};
|
||||
}
|
||||
|
||||
const stripeCli = createStripeCli({ org, env });
|
||||
const stripeCli = createStripeCli({ org, env });
|
||||
|
||||
try {
|
||||
let token = willFail ? "tok_chargeCustomerFail" : "tok_visa";
|
||||
const pm = await stripeCli.paymentMethods.create({
|
||||
type: "card",
|
||||
card: {
|
||||
token,
|
||||
},
|
||||
});
|
||||
await stripeCli.paymentMethods.attach(pm.id, {
|
||||
customer: stripeCusId,
|
||||
});
|
||||
try {
|
||||
const token = willFail ? "tok_chargeCustomerFail" : "tok_visa";
|
||||
const pm = await stripeCli.paymentMethods.create({
|
||||
type: "card",
|
||||
card: {
|
||||
token,
|
||||
},
|
||||
});
|
||||
await stripeCli.paymentMethods.attach(pm.id, {
|
||||
customer: stripeCusId,
|
||||
});
|
||||
|
||||
await stripeCli.customers.update(stripeCusId, {
|
||||
invoice_settings: {
|
||||
default_payment_method: pm.id,
|
||||
},
|
||||
});
|
||||
// console.log(" - Payment method attached");
|
||||
} catch (error) {
|
||||
console.log(" - Error attaching payment method", error);
|
||||
}
|
||||
await stripeCli.customers.update(stripeCusId, {
|
||||
invoice_settings: {
|
||||
default_payment_method: pm.id,
|
||||
},
|
||||
});
|
||||
// console.log(" - Payment method attached");
|
||||
} catch (error) {
|
||||
console.log(" - Error attaching payment method", error);
|
||||
}
|
||||
};
|
||||
|
||||
export const attachFailedPaymentMethod = async ({
|
||||
stripeCli,
|
||||
customer,
|
||||
stripeCli,
|
||||
customer,
|
||||
}: {
|
||||
stripeCli: Stripe;
|
||||
customer: Customer;
|
||||
stripeCli: Stripe;
|
||||
customer: Customer;
|
||||
}) => {
|
||||
// Delete existing payment method
|
||||
const paymentMethods = await stripeCli.paymentMethods.list({
|
||||
customer: customer.processor?.id,
|
||||
});
|
||||
for (const pm of paymentMethods.data) {
|
||||
await stripeCli.paymentMethods.detach(pm.id);
|
||||
}
|
||||
// Delete existing payment method
|
||||
const paymentMethods = await stripeCli.paymentMethods.list({
|
||||
customer: customer.processor?.id,
|
||||
});
|
||||
for (const pm of paymentMethods.data) {
|
||||
await stripeCli.paymentMethods.detach(pm.id);
|
||||
}
|
||||
|
||||
const pm = await stripeCli.paymentMethods.create({
|
||||
type: "card",
|
||||
card: {
|
||||
token: "tok_chargeCustomerFail",
|
||||
},
|
||||
});
|
||||
await stripeCli.paymentMethods.attach(pm.id, {
|
||||
customer: customer.processor?.id,
|
||||
});
|
||||
const pm = await stripeCli.paymentMethods.create({
|
||||
type: "card",
|
||||
card: {
|
||||
token: "tok_chargeCustomerFail",
|
||||
},
|
||||
});
|
||||
await stripeCli.paymentMethods.attach(pm.id, {
|
||||
customer: customer.processor?.id,
|
||||
});
|
||||
};
|
||||
|
||||
export const deleteAllStripeCustomers = async ({
|
||||
org,
|
||||
env,
|
||||
org,
|
||||
env,
|
||||
}: {
|
||||
org: Organization;
|
||||
env: AppEnv;
|
||||
org: Organization;
|
||||
env: AppEnv;
|
||||
}) => {
|
||||
const stripeCli = createStripeCli({ org, env });
|
||||
const stripeCli = createStripeCli({ org, env });
|
||||
|
||||
const stripeCustomers = await stripeCli.customers.list({
|
||||
limit: 100,
|
||||
});
|
||||
const stripeCustomers = await stripeCli.customers.list({
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
if (stripeCustomers.data.length === 0) {
|
||||
return;
|
||||
}
|
||||
if (stripeCustomers.data.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
let firstCustomer = stripeCustomers.data[0];
|
||||
if (firstCustomer.livemode) {
|
||||
throw new RecaseError({
|
||||
message: "Cannot delete livemode customers",
|
||||
code: ErrCode.StripeDeleteCustomerFailed,
|
||||
statusCode: StatusCodes.INTERNAL_SERVER_ERROR,
|
||||
});
|
||||
}
|
||||
const firstCustomer = stripeCustomers.data[0];
|
||||
if (firstCustomer.livemode) {
|
||||
throw new RecaseError({
|
||||
message: "Cannot delete livemode customers",
|
||||
code: ErrCode.StripeDeleteCustomerFailed,
|
||||
statusCode: StatusCodes.INTERNAL_SERVER_ERROR,
|
||||
});
|
||||
}
|
||||
|
||||
let batchSize = 10;
|
||||
for (let i = 0; i < stripeCustomers.data.length; i += batchSize) {
|
||||
let batch = stripeCustomers.data.slice(i, i + batchSize);
|
||||
await Promise.all(batch.map((c) => stripeCli.customers.del(c.id)));
|
||||
console.log(
|
||||
`Deleted ${i + batch.length}/${stripeCustomers.data.length} customers`
|
||||
);
|
||||
}
|
||||
const batchSize = 10;
|
||||
for (let i = 0; i < stripeCustomers.data.length; i += batchSize) {
|
||||
const batch = stripeCustomers.data.slice(i, i + batchSize);
|
||||
await Promise.all(batch.map((c) => stripeCli.customers.del(c.id)));
|
||||
console.log(
|
||||
`Deleted ${i + batch.length}/${stripeCustomers.data.length} customers`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
4
server/src/external/stripe/stripeSubUtils.ts
vendored
4
server/src/external/stripe/stripeSubUtils.ts
vendored
@@ -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;
|
||||
|
||||
36
server/src/external/stripe/stripeWebhooks.ts
vendored
36
server/src/external/stripe/stripeWebhooks.ts
vendored
@@ -9,7 +9,11 @@ import { handleCheckoutSessionCompleted } from "./webhookHandlers/handleCheckout
|
||||
import { handleSubscriptionUpdated } from "./webhookHandlers/handleSubUpdated.js";
|
||||
import { handleSubDeleted } from "./webhookHandlers/handleSubDeleted.js";
|
||||
import { handleSubCreated } from "./webhookHandlers/handleSubCreated.js";
|
||||
import { getStripeWebhookSecret } from "@/internal/orgs/orgUtils.js";
|
||||
import {
|
||||
getStripeWebhookSecret,
|
||||
isStripeConnected,
|
||||
unsetOrgStripeKeys,
|
||||
} from "@/internal/orgs/orgUtils.js";
|
||||
import { handleInvoicePaid } from "./webhookHandlers/handleInvoicePaid.js";
|
||||
import { handleRequestError } from "@/utils/errorUtils.js";
|
||||
import { handleInvoiceCreated } from "./webhookHandlers/handleInvoiceCreated/handleInvoiceCreated.js";
|
||||
@@ -22,6 +26,7 @@ import { deleteCusCache } from "@/internal/customers/cusCache/updateCachedCus.js
|
||||
import { CusService } from "@/internal/customers/CusService.js";
|
||||
import { DrizzleCli } from "@/db/initDrizzle.js";
|
||||
import { handleInvoiceUpdated } from "./webhookHandlers/handleInvoiceUpdated.js";
|
||||
import { disconnectStripe } from "@/internal/orgs/handlers/handleDeleteStripe.js";
|
||||
|
||||
export const stripeWebhookRouter: Router = express.Router();
|
||||
|
||||
@@ -66,9 +71,11 @@ stripeWebhookRouter.post(
|
||||
request.env = env;
|
||||
org = data.org;
|
||||
|
||||
if (!org.stripe_config) {
|
||||
console.log(`Org ${orgId} does not have a stripe config`);
|
||||
response.status(200).send(`Org ${orgId} does not have a stripe config`);
|
||||
if (!isStripeConnected({ org, env })) {
|
||||
console.log(`Org ${orgId} and env ${env} is not connected to stripe`);
|
||||
response
|
||||
.status(200)
|
||||
.send(`Org ${orgId} and env ${env} is not connected to stripe`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -115,15 +122,6 @@ stripeWebhookRouter.post(
|
||||
let logger = request.logtail;
|
||||
logStripeWebhook({ req: request, event });
|
||||
|
||||
// const logger = createLogtailWithContext({
|
||||
// action: LoggerAction.StripeWebhook,
|
||||
// event_type: event.type,
|
||||
// data: event.data,
|
||||
// org_id: orgId,
|
||||
// org_slug: org.slug,
|
||||
// env,
|
||||
// });
|
||||
|
||||
try {
|
||||
const stripeCli = createStripeCli({ org, env });
|
||||
switch (event.type) {
|
||||
@@ -244,6 +242,18 @@ stripeWebhookRouter.post(
|
||||
response.status(200).json({ message: "ok" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (error.message.includes("Expired API Key provided")) {
|
||||
// Disconnect Stripe
|
||||
await unsetOrgStripeKeys({
|
||||
db,
|
||||
org,
|
||||
env,
|
||||
});
|
||||
|
||||
response.status(200).json({ message: "ok" });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
handleRequestError({
|
||||
|
||||
@@ -1,29 +1,32 @@
|
||||
import { Stripe } from "stripe";
|
||||
import { createFullCusProduct } from "@/internal/customers/add-product/createFullCusProduct.js";
|
||||
import { CusProductService } from "@/internal/customers/cusProducts/CusProductService.js";
|
||||
import { getMetadataFromCheckoutSession } from "@/internal/metadata/metadataUtils.js";
|
||||
import {
|
||||
AppEnv,
|
||||
type AppEnv,
|
||||
AttachScenario,
|
||||
CusProductStatus,
|
||||
Organization,
|
||||
notNullish,
|
||||
type Organization,
|
||||
} from "@autumn/shared";
|
||||
import { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js";
|
||||
import { createStripeCli } from "../utils.js";
|
||||
|
||||
import type { Stripe } from "stripe";
|
||||
import type { DrizzleCli } from "@/db/initDrizzle.js";
|
||||
import { createFullCusProduct } from "@/internal/customers/add-product/createFullCusProduct.js";
|
||||
import { CusService } from "@/internal/customers/CusService.js";
|
||||
import type { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js";
|
||||
import { CusProductService } from "@/internal/customers/cusProducts/CusProductService.js";
|
||||
import { insertInvoiceFromAttach } from "@/internal/invoices/invoiceUtils.js";
|
||||
import { getMetadataFromCheckoutSession } from "@/internal/metadata/metadataUtils.js";
|
||||
import { attachToInsertParams } from "@/internal/products/productUtils.js";
|
||||
import { JobName } from "@/queue/JobName.js";
|
||||
import { addTaskToQueue } from "@/queue/queueUtils.js";
|
||||
import { insertInvoiceFromAttach } from "@/internal/invoices/invoiceUtils.js";
|
||||
import { DrizzleCli } from "@/db/initDrizzle.js";
|
||||
import { ExtendedRequest } from "@/utils/models/Request.js";
|
||||
|
||||
import type { ExtendedRequest } from "@/utils/models/Request.js";
|
||||
|
||||
import { createStripeCli } from "../utils.js";
|
||||
|
||||
import { handleCheckoutSub } from "./handleCheckoutCompleted/handleCheckoutSub.js";
|
||||
import { handleRemainingSets } from "./handleCheckoutCompleted/handleRemainingSets.js";
|
||||
|
||||
import { handleSetupCheckout } from "./handleCheckoutCompleted/handleSetupCheckout.js";
|
||||
import { getOptionsFromCheckoutSession } from "./handleCheckoutCompleted/getOptionsFromCheckout.js";
|
||||
import { getEarliestPeriodEnd } from "../stripeSubUtils/convertSubUtils.js";
|
||||
import { notNullish } from "@/utils/genUtils.js";
|
||||
import { CusService } from "@/internal/customers/CusService.js";
|
||||
import { handleSetupCheckout } from "./handleCheckoutCompleted/handleSetupCheckout.js";
|
||||
|
||||
export const handleCheckoutSessionCompleted = async ({
|
||||
req,
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1,107 +1,170 @@
|
||||
import type Stripe from "stripe";
|
||||
import type { DrizzleCli } from "@/db/initDrizzle.js";
|
||||
import { CusService } from "@/internal/customers/CusService.js";
|
||||
import { RewardRedemptionService } from "@/internal/rewards/RewardRedemptionService.js";
|
||||
import { createStripeCli } from "../utils.js";
|
||||
import Stripe from "stripe";
|
||||
import { notNullish, timeout } from "@/utils/genUtils.js";
|
||||
import { DrizzleCli } from "@/db/initDrizzle.js";
|
||||
import { RewardService } from "@/internal/rewards/RewardService.js";
|
||||
import { notNullish } from "@/utils/genUtils.js";
|
||||
import { createStripeCli } from "../utils.js";
|
||||
|
||||
export async function handleCusDiscountDeleted({
|
||||
db,
|
||||
org,
|
||||
discount,
|
||||
env,
|
||||
logger,
|
||||
res,
|
||||
db,
|
||||
org,
|
||||
discount,
|
||||
env,
|
||||
logger,
|
||||
res,
|
||||
}: {
|
||||
db: DrizzleCli;
|
||||
org: any;
|
||||
discount: any;
|
||||
env: any;
|
||||
logger: any;
|
||||
res: any;
|
||||
db: DrizzleCli;
|
||||
org: any;
|
||||
discount: any;
|
||||
env: any;
|
||||
logger: any;
|
||||
res: any;
|
||||
}) {
|
||||
let customer = await CusService.getByStripeId({
|
||||
db,
|
||||
stripeId: discount.customer,
|
||||
});
|
||||
const customer = await CusService.getByStripeId({
|
||||
db,
|
||||
stripeId: discount.customer,
|
||||
});
|
||||
|
||||
if (!customer) {
|
||||
logger.warn(`discount.deleted: customer ${discount.customer} not found`);
|
||||
return;
|
||||
}
|
||||
if (!customer) {
|
||||
logger.warn(`discount.deleted: customer ${discount.customer} not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (customer.env !== env || customer.org_id !== org.id) {
|
||||
logger.info(`discount.deleted: env or org mismatch, skipping`);
|
||||
return;
|
||||
}
|
||||
if (customer.env !== env || customer.org_id !== org.id) {
|
||||
logger.info(
|
||||
`discount.deleted: env or org mismatch, skipping, ${customer.env} !== ${env} || ${customer.org_id} !== ${org.id}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if any redemptions available, and apply to customer if so
|
||||
let redemptions = await RewardRedemptionService.getUnappliedRedemptions({
|
||||
db,
|
||||
internalCustomerId: customer.internal_id,
|
||||
});
|
||||
// Check if any redemptions available, and apply to customer if so
|
||||
const redemptions = await RewardRedemptionService.getUnappliedRedemptions({
|
||||
db,
|
||||
internalCustomerId: customer.internal_id,
|
||||
});
|
||||
|
||||
logger.info(`discount.deleted: found ${redemptions.length} redemptions`);
|
||||
logger.info(
|
||||
`discount.deleted:, discount ID: ${discount.id}, found ${redemptions.length} redemptions`,
|
||||
);
|
||||
|
||||
if (redemptions.length == 0) {
|
||||
return;
|
||||
}
|
||||
if (redemptions.length == 0) return;
|
||||
|
||||
let redemption = redemptions[0];
|
||||
const paidProductRedemption = redemptions.find(
|
||||
(r) =>
|
||||
r.reward_program.reward.id ===
|
||||
(typeof discount.coupon == "string"
|
||||
? discount.coupon
|
||||
: discount.coupon.id),
|
||||
);
|
||||
|
||||
// Apply redemption to customer
|
||||
let stripeCli = createStripeCli({
|
||||
org,
|
||||
env,
|
||||
});
|
||||
if (discount.subscription) {
|
||||
logger.info(
|
||||
`Discount is a subscription, paidProductRedemption: ${paidProductRedemption?.id}`,
|
||||
);
|
||||
|
||||
let stripeCus = (await stripeCli.customers.retrieve(
|
||||
discount.customer
|
||||
)) as Stripe.Customer;
|
||||
if (!paidProductRedemption) return;
|
||||
|
||||
if (stripeCus && notNullish(stripeCus.discount)) {
|
||||
logger.info(
|
||||
`discount.deleted: stripe customer ${discount.customer} already has a discount`
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Re-apply coupon and mark applied / redeemer applied to true
|
||||
const stripeCli = createStripeCli({
|
||||
org,
|
||||
env,
|
||||
});
|
||||
|
||||
const reward = await RewardService.get({
|
||||
db,
|
||||
orgId: org.id,
|
||||
env,
|
||||
idOrInternalId: redemption.reward_program.internal_reward_id!,
|
||||
});
|
||||
// Mark reward redemption as applied / redeemer applied to true
|
||||
|
||||
if (!reward) {
|
||||
logger.warn(
|
||||
`discount.deleted: reward ${redemption.reward_program.internal_id} not found`
|
||||
);
|
||||
return;
|
||||
}
|
||||
const sub = await stripeCli.subscriptions.retrieve(discount.subscription);
|
||||
|
||||
const legacyStripe = createStripeCli({
|
||||
org,
|
||||
env,
|
||||
legacyVersion: true,
|
||||
});
|
||||
// can't really test because it modifies subscription affected by test clock...
|
||||
try {
|
||||
await stripeCli.subscriptions.update(discount.subscription, {
|
||||
discounts: [
|
||||
...(sub.discounts as string[]).map((d: string) => ({
|
||||
discount: d,
|
||||
})),
|
||||
{
|
||||
coupon: paidProductRedemption.reward_program.reward.id as string,
|
||||
},
|
||||
],
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error(
|
||||
`Failed to update subscription ${discount.subscription} with paid product coupon, error: ${error.message}`,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
|
||||
await legacyStripe.customers.update(discount.customer, {
|
||||
// @ts-ignore
|
||||
coupon: reward.internal_id,
|
||||
});
|
||||
// Mark reward redemption as applied / redeemer applied to true
|
||||
const isReferrer =
|
||||
paidProductRedemption.referral_code.internal_customer_id ===
|
||||
customer.internal_id;
|
||||
|
||||
await RewardRedemptionService.update({
|
||||
db,
|
||||
id: redemption.id,
|
||||
updates: {
|
||||
applied: true,
|
||||
},
|
||||
});
|
||||
await RewardRedemptionService.update({
|
||||
db,
|
||||
id: paidProductRedemption.id,
|
||||
updates: {
|
||||
applied: isReferrer ? true : undefined,
|
||||
redeemer_applied: isReferrer ? undefined : true,
|
||||
},
|
||||
});
|
||||
|
||||
logger.info(
|
||||
`discount.deleted: applied reward ${reward.name} on customer ${customer.name} (${customer.id})`
|
||||
);
|
||||
logger.info(`Redemption ID: ${redemption.id}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const redemption = redemptions[0];
|
||||
|
||||
// Apply redemption to customer
|
||||
const stripeCli = createStripeCli({
|
||||
org,
|
||||
env,
|
||||
});
|
||||
|
||||
const stripeCus = (await stripeCli.customers.retrieve(
|
||||
discount.customer,
|
||||
)) as Stripe.Customer;
|
||||
|
||||
if (stripeCus && notNullish(stripeCus.discount)) {
|
||||
logger.info(
|
||||
`discount.deleted: stripe customer ${discount.customer} already has a discount`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const reward = await RewardService.get({
|
||||
db,
|
||||
orgId: org.id,
|
||||
env,
|
||||
idOrInternalId: redemption.reward_program.internal_reward_id!,
|
||||
});
|
||||
|
||||
if (!reward) {
|
||||
logger.warn(
|
||||
`discount.deleted: reward ${redemption.reward_program.internal_id} not found`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const legacyStripe = createStripeCli({
|
||||
org,
|
||||
env,
|
||||
legacyVersion: true,
|
||||
});
|
||||
|
||||
await legacyStripe.customers.update(discount.customer, {
|
||||
// @ts-expect-error
|
||||
coupon: reward.id,
|
||||
});
|
||||
|
||||
await RewardRedemptionService.update({
|
||||
db,
|
||||
id: redemption.id,
|
||||
updates: {
|
||||
applied: true,
|
||||
},
|
||||
});
|
||||
|
||||
logger.info(
|
||||
`discount.deleted: applied reward ${reward.name} on customer ${customer.name} (${customer.id})`,
|
||||
);
|
||||
logger.info(`Redemption ID: ${redemption.id}`);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
import Stripe from "stripe";
|
||||
import { CusProductService } from "@/internal/customers/cusProducts/CusProductService.js";
|
||||
import {
|
||||
import type {
|
||||
AppEnv,
|
||||
FullCusProduct,
|
||||
FullCustomerPrice,
|
||||
InvoiceStatus,
|
||||
Organization,
|
||||
} from "@autumn/shared";
|
||||
import { createStripeCli } from "../utils.js";
|
||||
|
||||
import type Stripe from "stripe";
|
||||
import type { DrizzleCli } from "@/db/initDrizzle.js";
|
||||
import { handleInvoiceCheckoutPaid } from "@/internal/customers/attach/attachFunctions/invoiceCheckoutPaid/handleInvoiceCheckoutPaid.js";
|
||||
import { CusProductService } from "@/internal/customers/cusProducts/CusProductService.js";
|
||||
import { InvoiceService } from "@/internal/invoices/InvoiceService.js";
|
||||
import { getInvoiceItems } from "@/internal/invoices/invoiceUtils.js";
|
||||
import { JobName } from "@/queue/JobName.js";
|
||||
import { addTaskToQueue } from "@/queue/queueUtils.js";
|
||||
import { nullish } from "@/utils/genUtils.js";
|
||||
import {
|
||||
getFullStripeInvoice,
|
||||
@@ -16,282 +20,277 @@ import {
|
||||
invoiceToSubId,
|
||||
updateInvoiceIfExists,
|
||||
} from "../stripeInvoiceUtils.js";
|
||||
import { getStripeSubs } from "../stripeSubUtils.js";
|
||||
import { addTaskToQueue } from "@/queue/queueUtils.js";
|
||||
import { JobName } from "@/queue/JobName.js";
|
||||
import { DrizzleCli } from "@/db/initDrizzle.js";
|
||||
import { InvoiceService } from "@/internal/invoices/InvoiceService.js";
|
||||
import { getInvoiceItems } from "@/internal/invoices/invoiceUtils.js";
|
||||
import { handleInvoicePaidDiscount } from "./handleInvoicePaidDiscount.js";
|
||||
import { handleInvoiceCheckoutPaid } from "@/internal/customers/attach/attachFunctions/invoiceCheckoutPaid/handleInvoiceCheckoutPaid.js";
|
||||
import { lineItemInCusProduct } from "../stripeSubUtils/stripeSubItemUtils.js";
|
||||
import { getStripeSubs } from "../stripeSubUtils.js";
|
||||
import { createStripeCli } from "../utils.js";
|
||||
import { handleInvoicePaidDiscount } from "./handleInvoicePaidDiscount.js";
|
||||
|
||||
const handleOneOffInvoicePaid = async ({
|
||||
db,
|
||||
stripeInvoice,
|
||||
logger,
|
||||
db,
|
||||
stripeInvoice,
|
||||
logger,
|
||||
}: {
|
||||
db: DrizzleCli;
|
||||
stripeInvoice: Stripe.Invoice;
|
||||
event: Stripe.Event;
|
||||
logger: any;
|
||||
db: DrizzleCli;
|
||||
stripeInvoice: Stripe.Invoice;
|
||||
event: Stripe.Event;
|
||||
logger: any;
|
||||
}) => {
|
||||
// Search for invoice
|
||||
const invoice = await InvoiceService.getByStripeId({
|
||||
db,
|
||||
stripeId: stripeInvoice.id!,
|
||||
});
|
||||
// Search for invoice
|
||||
const invoice = await InvoiceService.getByStripeId({
|
||||
db,
|
||||
stripeId: stripeInvoice.id!,
|
||||
});
|
||||
|
||||
if (!invoice) {
|
||||
console.log(`Invoice not found`);
|
||||
return;
|
||||
}
|
||||
if (!invoice) {
|
||||
console.log(`Invoice not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Update invoice status
|
||||
await InvoiceService.updateByStripeId({
|
||||
db,
|
||||
stripeId: stripeInvoice.id!,
|
||||
updates: {
|
||||
status: stripeInvoice.status as InvoiceStatus,
|
||||
hosted_invoice_url: stripeInvoice.hosted_invoice_url,
|
||||
discounts: getInvoiceDiscounts({
|
||||
expandedInvoice: stripeInvoice,
|
||||
}),
|
||||
},
|
||||
});
|
||||
// Update invoice status
|
||||
await InvoiceService.updateByStripeId({
|
||||
db,
|
||||
stripeId: stripeInvoice.id!,
|
||||
updates: {
|
||||
status: stripeInvoice.status as InvoiceStatus,
|
||||
hosted_invoice_url: stripeInvoice.hosted_invoice_url,
|
||||
discounts: getInvoiceDiscounts({
|
||||
expandedInvoice: stripeInvoice,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`Updated one off invoice status to ${stripeInvoice.status}`);
|
||||
console.log(`Updated one off invoice status to ${stripeInvoice.status}`);
|
||||
};
|
||||
|
||||
const convertToChargeAutomatically = async ({
|
||||
org,
|
||||
env,
|
||||
invoice,
|
||||
activeCusProducts,
|
||||
logger,
|
||||
org,
|
||||
env,
|
||||
invoice,
|
||||
activeCusProducts,
|
||||
logger,
|
||||
}: {
|
||||
org: Organization;
|
||||
env: AppEnv;
|
||||
invoice: Stripe.Invoice;
|
||||
activeCusProducts: FullCusProduct[];
|
||||
logger: any;
|
||||
org: Organization;
|
||||
env: AppEnv;
|
||||
invoice: Stripe.Invoice;
|
||||
activeCusProducts: FullCusProduct[];
|
||||
logger: any;
|
||||
}) => {
|
||||
const stripeCli = createStripeCli({ org, env });
|
||||
const stripeCli = createStripeCli({ org, env });
|
||||
|
||||
const subs = await getStripeSubs({
|
||||
stripeCli,
|
||||
subIds: activeCusProducts.flatMap((p) => p.subscription_ids || []),
|
||||
});
|
||||
const subs = await getStripeSubs({
|
||||
stripeCli,
|
||||
subIds: activeCusProducts.flatMap((p) => p.subscription_ids || []),
|
||||
});
|
||||
|
||||
const payments = invoice.payments;
|
||||
const firstPayment = payments?.data?.[0];
|
||||
const paymentIntentId = firstPayment?.payment?.payment_intent as string;
|
||||
const payments = invoice.payments;
|
||||
const firstPayment = payments?.data?.[0];
|
||||
const paymentIntentId = firstPayment?.payment?.payment_intent as string;
|
||||
|
||||
if (
|
||||
subs.every((s) => s.collection_method === "charge_automatically") ||
|
||||
nullish(paymentIntentId)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
subs.every((s) => s.collection_method === "charge_automatically") ||
|
||||
nullish(paymentIntentId)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get payment intent...
|
||||
// Get payment intent...
|
||||
|
||||
// Try to attach payment method to subscription
|
||||
try {
|
||||
logger.info(`Converting to charge automatically`);
|
||||
// 1. Get payment intent
|
||||
const paymentIntent =
|
||||
await stripeCli.paymentIntents.retrieve(paymentIntentId);
|
||||
// Try to attach payment method to subscription
|
||||
try {
|
||||
logger.info(`Converting to charge automatically`);
|
||||
// 1. Get payment intent
|
||||
const paymentIntent =
|
||||
await stripeCli.paymentIntents.retrieve(paymentIntentId);
|
||||
|
||||
// 2. Get payment method
|
||||
const paymentMethod = await stripeCli.paymentMethods.retrieve(
|
||||
paymentIntent.payment_method as string
|
||||
);
|
||||
// 2. Get payment method
|
||||
const paymentMethod = await stripeCli.paymentMethods.retrieve(
|
||||
paymentIntent.payment_method as string,
|
||||
);
|
||||
|
||||
await stripeCli.paymentMethods.attach(paymentMethod.id, {
|
||||
customer: invoice.customer as string,
|
||||
});
|
||||
await stripeCli.paymentMethods.attach(paymentMethod.id, {
|
||||
customer: invoice.customer as string,
|
||||
});
|
||||
|
||||
const batchUpdateSubs = [];
|
||||
const updateSub = async (sub: Stripe.Subscription) => {
|
||||
try {
|
||||
await stripeCli.subscriptions.update(sub.id, {
|
||||
collection_method: "charge_automatically",
|
||||
default_payment_method: paymentMethod.id,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
`Convert to charge automatically: error updating subscription ${sub.id}`
|
||||
);
|
||||
logger.warn(error);
|
||||
}
|
||||
};
|
||||
const batchUpdateSubs = [];
|
||||
const updateSub = async (sub: Stripe.Subscription) => {
|
||||
try {
|
||||
await stripeCli.subscriptions.update(sub.id, {
|
||||
collection_method: "charge_automatically",
|
||||
default_payment_method: paymentMethod.id,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
`Convert to charge automatically: error updating subscription ${sub.id}`,
|
||||
);
|
||||
logger.warn(error);
|
||||
}
|
||||
};
|
||||
|
||||
for (const sub of subs) {
|
||||
batchUpdateSubs.push(updateSub(sub));
|
||||
}
|
||||
for (const sub of subs) {
|
||||
batchUpdateSubs.push(updateSub(sub));
|
||||
}
|
||||
|
||||
await Promise.all(batchUpdateSubs);
|
||||
await Promise.all(batchUpdateSubs);
|
||||
|
||||
logger.info("Convert to charge automatically successful!");
|
||||
} catch (error) {
|
||||
logger.warn(`Convert to charge automatically failed: ${error}`);
|
||||
}
|
||||
logger.info("Convert to charge automatically successful!");
|
||||
} catch (error) {
|
||||
logger.warn(`Convert to charge automatically failed: ${error}`);
|
||||
}
|
||||
};
|
||||
|
||||
export const handleInvoicePaid = async ({
|
||||
db,
|
||||
req,
|
||||
org,
|
||||
invoiceData,
|
||||
env,
|
||||
event,
|
||||
db,
|
||||
req,
|
||||
org,
|
||||
invoiceData,
|
||||
env,
|
||||
event,
|
||||
}: {
|
||||
db: DrizzleCli;
|
||||
req: any;
|
||||
org: Organization;
|
||||
invoiceData: Stripe.Invoice;
|
||||
env: AppEnv;
|
||||
event: Stripe.Event;
|
||||
db: DrizzleCli;
|
||||
req: any;
|
||||
org: Organization;
|
||||
invoiceData: Stripe.Invoice;
|
||||
env: AppEnv;
|
||||
event: Stripe.Event;
|
||||
}) => {
|
||||
const logger = req.logtail;
|
||||
const stripeCli = createStripeCli({ org, env });
|
||||
const invoice = await getFullStripeInvoice({
|
||||
stripeCli,
|
||||
stripeId: invoiceData.id!,
|
||||
expand: ["payments"],
|
||||
});
|
||||
const logger = req.logtail;
|
||||
const stripeCli = createStripeCli({ org, env });
|
||||
const invoice = await getFullStripeInvoice({
|
||||
stripeCli,
|
||||
stripeId: invoiceData.id!,
|
||||
expand: ["payments"],
|
||||
});
|
||||
|
||||
if (invoice.metadata?.autumn_metadata_id) {
|
||||
await handleInvoiceCheckoutPaid({
|
||||
req,
|
||||
org,
|
||||
env,
|
||||
db,
|
||||
stripeCli,
|
||||
invoice,
|
||||
});
|
||||
}
|
||||
if (invoice.metadata?.autumn_metadata_id) {
|
||||
await handleInvoiceCheckoutPaid({
|
||||
req,
|
||||
org,
|
||||
env,
|
||||
db,
|
||||
stripeCli,
|
||||
invoice,
|
||||
});
|
||||
}
|
||||
|
||||
await handleInvoicePaidDiscount({
|
||||
db,
|
||||
expandedInvoice: invoice,
|
||||
org,
|
||||
env,
|
||||
logger,
|
||||
});
|
||||
await handleInvoicePaidDiscount({
|
||||
db,
|
||||
expandedInvoice: invoice,
|
||||
org,
|
||||
env,
|
||||
logger,
|
||||
});
|
||||
|
||||
const subId = invoiceToSubId({ invoice });
|
||||
if (subId) {
|
||||
// Get customer product
|
||||
const activeCusProducts = await CusProductService.getByStripeSubId({
|
||||
db,
|
||||
stripeSubId: subId,
|
||||
orgId: org.id,
|
||||
env,
|
||||
});
|
||||
const subId = invoiceToSubId({ invoice });
|
||||
if (subId) {
|
||||
// Get customer product
|
||||
const activeCusProducts = await CusProductService.getByStripeSubId({
|
||||
db,
|
||||
stripeSubId: subId,
|
||||
orgId: org.id,
|
||||
env,
|
||||
});
|
||||
|
||||
if (!activeCusProducts || activeCusProducts.length === 0) {
|
||||
// TODO: Send alert
|
||||
if (invoice.livemode) {
|
||||
logger.warn(
|
||||
`invoice.paid: customer product not found for invoice ${invoice.id}`
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!activeCusProducts || activeCusProducts.length === 0) {
|
||||
// TODO: Send alert
|
||||
if (invoice.livemode) {
|
||||
logger.warn(
|
||||
`invoice.paid: customer product not found for invoice ${invoice.id}`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (org.config.convert_to_charge_automatically) {
|
||||
await convertToChargeAutomatically({
|
||||
org,
|
||||
env,
|
||||
invoice,
|
||||
activeCusProducts,
|
||||
logger,
|
||||
});
|
||||
}
|
||||
if (org.config.convert_to_charge_automatically) {
|
||||
await convertToChargeAutomatically({
|
||||
org,
|
||||
env,
|
||||
invoice,
|
||||
activeCusProducts,
|
||||
logger,
|
||||
});
|
||||
}
|
||||
|
||||
const updated = await updateInvoiceIfExists({
|
||||
db,
|
||||
invoice,
|
||||
});
|
||||
const updated = await updateInvoiceIfExists({
|
||||
db,
|
||||
invoice,
|
||||
});
|
||||
|
||||
if (!updated) {
|
||||
let invoiceItems = await getInvoiceItems({
|
||||
stripeInvoice: invoice,
|
||||
prices: activeCusProducts.flatMap((p) =>
|
||||
p.customer_prices.map((cpr: FullCustomerPrice) => cpr.price)
|
||||
),
|
||||
logger,
|
||||
});
|
||||
if (!updated) {
|
||||
const invoiceItems = await getInvoiceItems({
|
||||
stripeInvoice: invoice,
|
||||
prices: activeCusProducts.flatMap((p) =>
|
||||
p.customer_prices.map((cpr: FullCustomerPrice) => cpr.price),
|
||||
),
|
||||
logger,
|
||||
});
|
||||
|
||||
const invoiceLines = invoice.lines.data;
|
||||
let cusProducts: FullCusProduct[] = activeCusProducts;
|
||||
try {
|
||||
cusProducts = activeCusProducts.filter((cp) =>
|
||||
invoiceLines.some((l) =>
|
||||
lineItemInCusProduct({ cusProduct: cp, lineItem: l })
|
||||
)
|
||||
);
|
||||
const invoiceLines = invoice.lines.data;
|
||||
let cusProducts: FullCusProduct[] = activeCusProducts;
|
||||
try {
|
||||
cusProducts = activeCusProducts.filter((cp) =>
|
||||
invoiceLines.some((l) =>
|
||||
lineItemInCusProduct({ cusProduct: cp, lineItem: l }),
|
||||
),
|
||||
);
|
||||
|
||||
console.log(
|
||||
"Invoice paid, filtered cus products:",
|
||||
cusProducts.map((cp) => `${cp.product.name} - ${cp.product.id}`)
|
||||
);
|
||||
console.log(
|
||||
"Invoice paid, filtered cus products:",
|
||||
cusProducts.map((cp) => `${cp.product.name} - ${cp.product.id}`),
|
||||
);
|
||||
|
||||
if (cusProducts.length == 0) {
|
||||
cusProducts = activeCusProducts;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("Failed to filter cus products for invoice");
|
||||
logger.error({ error });
|
||||
}
|
||||
if (cusProducts.length == 0) {
|
||||
cusProducts = activeCusProducts;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("Failed to filter cus products for invoice");
|
||||
logger.error({ error });
|
||||
}
|
||||
|
||||
const internalEntityId = new Set(
|
||||
cusProducts.map((cp) => cp.internal_entity_id)
|
||||
);
|
||||
const internalEntityId = new Set(
|
||||
cusProducts.map((cp) => cp.internal_entity_id),
|
||||
);
|
||||
|
||||
await InvoiceService.createInvoiceFromStripe({
|
||||
db,
|
||||
stripeInvoice: invoice,
|
||||
internalCustomerId: activeCusProducts[0].internal_customer_id,
|
||||
internalEntityId:
|
||||
internalEntityId.size > 1
|
||||
? undefined
|
||||
: internalEntityId.values().next().value,
|
||||
await InvoiceService.createInvoiceFromStripe({
|
||||
db,
|
||||
stripeInvoice: invoice,
|
||||
internalCustomerId: activeCusProducts[0].internal_customer_id,
|
||||
internalEntityId:
|
||||
internalEntityId.size > 1
|
||||
? undefined
|
||||
: internalEntityId.values().next().value,
|
||||
|
||||
productIds: [...new Set(cusProducts.map((p) => p.product_id))],
|
||||
internalProductIds: [
|
||||
...new Set(cusProducts.map((p) => p.internal_product_id)),
|
||||
],
|
||||
org: org,
|
||||
items: invoiceItems,
|
||||
});
|
||||
}
|
||||
productIds: [...new Set(cusProducts.map((p) => p.product_id))],
|
||||
internalProductIds: [
|
||||
...new Set(cusProducts.map((p) => p.internal_product_id)),
|
||||
],
|
||||
org: org,
|
||||
items: invoiceItems,
|
||||
});
|
||||
}
|
||||
|
||||
for (const cusProd of activeCusProducts) {
|
||||
try {
|
||||
await addTaskToQueue({
|
||||
jobName: JobName.TriggerCheckoutReward,
|
||||
payload: {
|
||||
customer: cusProd.customer,
|
||||
product: cusProd.product,
|
||||
org,
|
||||
env: cusProd.customer!.env,
|
||||
subId: cusProd.subscription_ids?.[0],
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(`invoice.paid: failed to trigger checkout reward check`);
|
||||
logger.error(error);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
await handleOneOffInvoicePaid({
|
||||
db,
|
||||
stripeInvoice: invoice,
|
||||
event,
|
||||
logger,
|
||||
});
|
||||
}
|
||||
for (const cusProd of activeCusProducts) {
|
||||
try {
|
||||
await addTaskToQueue({
|
||||
jobName: JobName.TriggerCheckoutReward,
|
||||
payload: {
|
||||
customer: cusProd.customer,
|
||||
product: cusProd.product,
|
||||
org,
|
||||
env: cusProd.customer!.env,
|
||||
subId: cusProd.subscription_ids?.[0],
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(`invoice.paid: failed to trigger checkout reward check`);
|
||||
logger.error(error);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
await handleOneOffInvoicePaid({
|
||||
db,
|
||||
stripeInvoice: invoice,
|
||||
event,
|
||||
logger,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,181 +1,183 @@
|
||||
import { DrizzleCli } from "@/db/initDrizzle.js";
|
||||
import { RewardService } from "@/internal/rewards/RewardService.js";
|
||||
import {
|
||||
type AppEnv,
|
||||
CouponDurationType,
|
||||
Organization,
|
||||
Reward,
|
||||
type Organization,
|
||||
type Reward,
|
||||
RewardType,
|
||||
} from "@autumn/shared";
|
||||
import { AppEnv } from "@autumn/shared";
|
||||
import { addMonths } from "date-fns";
|
||||
import { Decimal } from "decimal.js";
|
||||
|
||||
import Stripe from "stripe";
|
||||
import type Stripe from "stripe";
|
||||
import type { DrizzleCli } from "@/db/initDrizzle.js";
|
||||
import { RewardService } from "@/internal/rewards/RewardService.js";
|
||||
import { generateId } from "@/utils/genUtils.js";
|
||||
import { getStripeNow } from "@/utils/scriptUtils/testClockUtils.js";
|
||||
import {
|
||||
deleteCouponFromCus,
|
||||
deleteCouponFromSub,
|
||||
} from "../stripeCouponUtils/deleteCouponFromCus.js";
|
||||
import { createStripeCli } from "../utils.js";
|
||||
import { Decimal } from "decimal.js";
|
||||
import { generateId } from "@/utils/genUtils.js";
|
||||
import { addMonths } from "date-fns";
|
||||
import { getStripeNow } from "@/utils/scriptUtils/testClockUtils.js";
|
||||
import { invoiceToSubId } from "../stripeInvoiceUtils.js";
|
||||
import { createStripeCli } from "../utils.js";
|
||||
|
||||
export const handleInvoicePaidDiscount = async ({
|
||||
db,
|
||||
expandedInvoice,
|
||||
org,
|
||||
env,
|
||||
logger,
|
||||
db,
|
||||
expandedInvoice,
|
||||
org,
|
||||
env,
|
||||
logger,
|
||||
}: {
|
||||
db: DrizzleCli;
|
||||
expandedInvoice: Stripe.Invoice;
|
||||
org: Organization;
|
||||
env: AppEnv;
|
||||
logger: any;
|
||||
db: DrizzleCli;
|
||||
expandedInvoice: Stripe.Invoice;
|
||||
org: Organization;
|
||||
env: AppEnv;
|
||||
logger: any;
|
||||
}) => {
|
||||
// Handle coupon
|
||||
const stripeCli = createStripeCli({ org, env });
|
||||
if (expandedInvoice.discounts.length === 0) {
|
||||
return;
|
||||
}
|
||||
// Handle coupon
|
||||
const stripeCli = createStripeCli({ org, env });
|
||||
if (expandedInvoice.discounts.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
let stripeCus = await stripeCli.customers.retrieve(
|
||||
expandedInvoice.customer as string
|
||||
);
|
||||
const stripeCus = await stripeCli.customers.retrieve(
|
||||
expandedInvoice.customer as string,
|
||||
);
|
||||
|
||||
try {
|
||||
const totalDiscountAmounts = expandedInvoice.total_discount_amounts;
|
||||
try {
|
||||
const totalDiscountAmounts = expandedInvoice.total_discount_amounts;
|
||||
|
||||
// Log coupon information for debugging
|
||||
for (const discount of expandedInvoice.discounts) {
|
||||
if (typeof discount === "string") {
|
||||
continue;
|
||||
}
|
||||
// Log coupon information for debugging
|
||||
for (const discount of expandedInvoice.discounts) {
|
||||
if (typeof discount === "string") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const curCoupon = discount.coupon;
|
||||
const curCoupon = discount.coupon;
|
||||
|
||||
if (!curCoupon) {
|
||||
continue;
|
||||
}
|
||||
if (!curCoupon) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const rollSuffixIndex = curCoupon.id.indexOf("_roll_");
|
||||
const couponId =
|
||||
rollSuffixIndex !== -1
|
||||
? curCoupon.id.substring(0, rollSuffixIndex)
|
||||
: curCoupon.id;
|
||||
const rollSuffixIndex = curCoupon.id.indexOf("_roll_");
|
||||
const couponId =
|
||||
rollSuffixIndex !== -1
|
||||
? curCoupon.id.substring(0, rollSuffixIndex)
|
||||
: curCoupon.id;
|
||||
|
||||
const autumnReward: Reward | null = await RewardService.get({
|
||||
db,
|
||||
idOrInternalId: couponId,
|
||||
orgId: org.id,
|
||||
env,
|
||||
});
|
||||
const autumnReward: Reward | null = await RewardService.get({
|
||||
db,
|
||||
idOrInternalId: couponId,
|
||||
orgId: org.id,
|
||||
env,
|
||||
});
|
||||
|
||||
let shouldRollover =
|
||||
autumnReward && autumnReward.type == RewardType.InvoiceCredits;
|
||||
const shouldRollover =
|
||||
autumnReward &&
|
||||
(autumnReward.type === RewardType.InvoiceCredits ||
|
||||
autumnReward.type === RewardType.FreeProduct);
|
||||
|
||||
if (!shouldRollover) {
|
||||
continue;
|
||||
}
|
||||
if (!shouldRollover) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get ID of coupon
|
||||
const originalCoupon = await stripeCli.coupons.retrieve(couponId, {
|
||||
expand: ["applies_to"],
|
||||
});
|
||||
// Get ID of coupon
|
||||
const originalCoupon = await stripeCli.coupons.retrieve(couponId, {
|
||||
expand: ["applies_to"],
|
||||
});
|
||||
|
||||
// 1. New amount:
|
||||
const curAmount = discount.coupon.amount_off;
|
||||
// 1. New amount:
|
||||
const curAmount = discount.coupon.amount_off;
|
||||
|
||||
const amountUsed = totalDiscountAmounts?.find(
|
||||
(item) => item.discount === discount.id
|
||||
)?.amount;
|
||||
const amountUsed = totalDiscountAmounts?.find(
|
||||
(item) => item.discount === discount.id,
|
||||
)?.amount;
|
||||
|
||||
const newAmount = new Decimal(curAmount!).sub(amountUsed!).toNumber();
|
||||
const newAmount = new Decimal(curAmount!).sub(amountUsed!).toNumber();
|
||||
|
||||
let curExpiresAt = curCoupon.metadata?.expires_at
|
||||
? Number(curCoupon.metadata.expires_at)
|
||||
: null;
|
||||
const curExpiresAt = curCoupon.metadata?.expires_at
|
||||
? Number(curCoupon.metadata.expires_at)
|
||||
: null;
|
||||
|
||||
let discountFinished = newAmount <= 0;
|
||||
const discountFinished = newAmount <= 0;
|
||||
|
||||
let now = await getStripeNow({
|
||||
stripeCli,
|
||||
stripeCus: stripeCus as Stripe.Customer,
|
||||
});
|
||||
const now = await getStripeNow({
|
||||
stripeCli,
|
||||
stripeCus: stripeCus as Stripe.Customer,
|
||||
});
|
||||
|
||||
let expired = curExpiresAt && curExpiresAt < now;
|
||||
const subId = invoiceToSubId({ invoice: expandedInvoice });
|
||||
const expired = curExpiresAt && curExpiresAt < now;
|
||||
const subId = invoiceToSubId({ invoice: expandedInvoice });
|
||||
|
||||
if (discountFinished || expired) {
|
||||
logger.info(
|
||||
`Coupon ${couponId}, stripeCus: ${stripeCus.id}: credits used up or expired. discountFinished: ${discountFinished}, expired: ${expired}`
|
||||
);
|
||||
if (discountFinished || expired) {
|
||||
logger.info(
|
||||
`Coupon ${couponId}, stripeCus: ${stripeCus.id}: credits used up or expired. discountFinished: ${discountFinished}, expired: ${expired}`,
|
||||
);
|
||||
|
||||
if (subId) {
|
||||
await deleteCouponFromCus({
|
||||
stripeCli,
|
||||
stripeSubId: subId,
|
||||
stripeCusId: expandedInvoice.customer as string,
|
||||
discountId: discount.id,
|
||||
logger,
|
||||
});
|
||||
}
|
||||
if (subId) {
|
||||
await deleteCouponFromCus({
|
||||
stripeCli,
|
||||
stripeSubId: subId,
|
||||
stripeCusId: expandedInvoice.customer as string,
|
||||
discountId: discount.id,
|
||||
logger,
|
||||
});
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`Coupon ${couponId}, stripeCus: ${stripeCus.id}, updating amount from ${curAmount} to ${newAmount}`
|
||||
);
|
||||
logger.info(
|
||||
`Coupon ${couponId}, stripeCus: ${stripeCus.id}, updating amount from ${curAmount} to ${newAmount}`,
|
||||
);
|
||||
|
||||
// Set expiry date
|
||||
let expiresAt = curCoupon.metadata?.expires_at || null;
|
||||
let discountConfig = autumnReward?.discount_config;
|
||||
if (discountConfig?.duration_type == CouponDurationType.Months) {
|
||||
expiresAt = addMonths(new Date(), discountConfig.duration_value)
|
||||
.getTime()
|
||||
.toString();
|
||||
}
|
||||
// Set expiry date
|
||||
let expiresAt = curCoupon.metadata?.expires_at || null;
|
||||
const discountConfig = autumnReward?.discount_config;
|
||||
if (discountConfig?.duration_type === CouponDurationType.Months) {
|
||||
expiresAt = addMonths(new Date(), discountConfig.duration_value)
|
||||
.getTime()
|
||||
.toString();
|
||||
}
|
||||
|
||||
const newCoupon = await stripeCli.coupons.create({
|
||||
id: `${couponId}_${generateId("roll")}`,
|
||||
name: discount.coupon.name as string,
|
||||
amount_off: newAmount,
|
||||
currency: expandedInvoice.currency,
|
||||
duration: "once",
|
||||
applies_to: originalCoupon.applies_to,
|
||||
metadata: {
|
||||
expires_at: expiresAt,
|
||||
},
|
||||
});
|
||||
const newCoupon = await stripeCli.coupons.create({
|
||||
id: `${couponId}_${generateId("roll")}`,
|
||||
name: discount.coupon.name as string,
|
||||
amount_off: newAmount,
|
||||
currency: expandedInvoice.currency,
|
||||
duration: "once",
|
||||
applies_to: originalCoupon.applies_to,
|
||||
metadata: {
|
||||
expires_at: expiresAt,
|
||||
},
|
||||
});
|
||||
|
||||
const legacyStripeCli = createStripeCli({
|
||||
org,
|
||||
env,
|
||||
legacyVersion: true,
|
||||
});
|
||||
const legacyStripeCli = createStripeCli({
|
||||
org,
|
||||
env,
|
||||
legacyVersion: true,
|
||||
});
|
||||
|
||||
await legacyStripeCli.rawRequest(
|
||||
"POST",
|
||||
`/v1/customers/${expandedInvoice.customer}`,
|
||||
{
|
||||
coupon: newCoupon.id,
|
||||
}
|
||||
);
|
||||
await legacyStripeCli.rawRequest(
|
||||
"POST",
|
||||
`/v1/customers/${expandedInvoice.customer}`,
|
||||
{
|
||||
coupon: newCoupon.id,
|
||||
},
|
||||
);
|
||||
|
||||
await stripeCli.coupons.del(newCoupon.id);
|
||||
await stripeCli.coupons.del(newCoupon.id);
|
||||
|
||||
if (subId) {
|
||||
await deleteCouponFromSub({
|
||||
stripeCli,
|
||||
stripeSubId: subId,
|
||||
discountId: discount.id,
|
||||
logger,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("invoice.paid: error updating coupon");
|
||||
logger.error(error);
|
||||
}
|
||||
if (subId) {
|
||||
await deleteCouponFromSub({
|
||||
stripeCli,
|
||||
stripeSubId: subId,
|
||||
discountId: discount.id,
|
||||
logger,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("invoice.paid: error updating coupon");
|
||||
logger.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { RewardRedemptionService } from "@/internal/rewards/RewardRedemptionService.js";
|
||||
import { routeHandler } from "@/utils/routerUtils.js";
|
||||
|
||||
export default async (req: any, res: any) =>
|
||||
routeHandler({
|
||||
req,
|
||||
res,
|
||||
action: "get redemption by id",
|
||||
handler: async (req, res) => {
|
||||
const { db } = req;
|
||||
const { redemptionId } = req.params;
|
||||
|
||||
const redemption = await RewardRedemptionService.getById({
|
||||
db,
|
||||
id: redemptionId,
|
||||
});
|
||||
|
||||
res.status(200).json(redemption);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import { ErrCode } from "@autumn/shared";
|
||||
import { CusService } from "@/internal/customers/CusService.js";
|
||||
import { RewardProgramService } from "@/internal/rewards/RewardProgramService.js";
|
||||
import { generateReferralCode } from "@/internal/rewards/referralUtils.js";
|
||||
import RecaseError from "@/utils/errorUtils.js";
|
||||
import { generateId } from "@/utils/genUtils.js";
|
||||
import { routeHandler } from "@/utils/routerUtils.js";
|
||||
|
||||
export default async (req: any, res: any) =>
|
||||
routeHandler({
|
||||
req,
|
||||
res,
|
||||
action: "get referral code",
|
||||
handler: async (req, res) => {
|
||||
const { orgId, env, db } = req;
|
||||
const { program_id: rewardProgramId, customer_id: customerId } = req.body;
|
||||
|
||||
const [rewardProgram, customer] = await Promise.all([
|
||||
RewardProgramService.get({
|
||||
db,
|
||||
id: rewardProgramId,
|
||||
orgId,
|
||||
env,
|
||||
errorIfNotFound: true,
|
||||
}),
|
||||
CusService.get({
|
||||
db: req.db,
|
||||
orgId,
|
||||
env,
|
||||
idOrInternalId: customerId,
|
||||
}),
|
||||
]);
|
||||
|
||||
if (!customer) {
|
||||
throw new RecaseError({
|
||||
message: "Customer not found",
|
||||
statusCode: 404,
|
||||
code: ErrCode.CustomerNotFound,
|
||||
});
|
||||
}
|
||||
|
||||
if (!rewardProgram) {
|
||||
throw new RecaseError({
|
||||
message: "Reward program not found",
|
||||
statusCode: 404,
|
||||
code: ErrCode.RewardProgramNotFound,
|
||||
});
|
||||
}
|
||||
|
||||
// Get referral code by customer and reward trigger
|
||||
let referralCode =
|
||||
await RewardProgramService.getCodeByCustomerAndRewardProgram({
|
||||
db,
|
||||
orgId,
|
||||
env,
|
||||
internalCustomerId: customer.internal_id,
|
||||
internalRewardProgramId: rewardProgram.internal_id,
|
||||
});
|
||||
|
||||
if (!referralCode) {
|
||||
const code = generateReferralCode();
|
||||
|
||||
referralCode = {
|
||||
code,
|
||||
org_id: orgId,
|
||||
env,
|
||||
internal_customer_id: customer.internal_id,
|
||||
internal_reward_program_id: rewardProgram.internal_id,
|
||||
id: generateId("rc"),
|
||||
created_at: Date.now(),
|
||||
};
|
||||
|
||||
referralCode = await RewardProgramService.createReferralCode({
|
||||
db,
|
||||
data: referralCode,
|
||||
});
|
||||
}
|
||||
|
||||
res.status(200).json({
|
||||
code: referralCode.code,
|
||||
customer_id: customer.id,
|
||||
created_at: referralCode.created_at,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,218 @@
|
||||
import {
|
||||
ErrCode,
|
||||
RewardCategory,
|
||||
type RewardRedemption,
|
||||
RewardTriggerEvent,
|
||||
} from "@autumn/shared";
|
||||
import { parseReqForAction } from "@/internal/analytics/actionUtils.js";
|
||||
import { CusService } from "@/internal/customers/CusService.js";
|
||||
import { OrgService } from "@/internal/orgs/OrgService.js";
|
||||
import { RewardProgramService } from "@/internal/rewards/RewardProgramService.js";
|
||||
import { RewardRedemptionService } from "@/internal/rewards/RewardRedemptionService.js";
|
||||
import { RewardService } from "@/internal/rewards/RewardService.js";
|
||||
import { triggerRedemption } from "@/internal/rewards/referralUtils.js";
|
||||
import { triggerFreeProduct } from "@/internal/rewards/referralUtils/triggerFreeProduct.js";
|
||||
import { getRewardCat } from "@/internal/rewards/rewardUtils.js";
|
||||
import RecaseError from "@/utils/errorUtils.js";
|
||||
import { generateId, notNullish } from "@/utils/genUtils.js";
|
||||
import type { ExtendedRequest } from "@/utils/models/Request.js";
|
||||
import { routeHandler } from "@/utils/routerUtils.js";
|
||||
|
||||
export default async (req: any, res: any) =>
|
||||
routeHandler({
|
||||
req,
|
||||
res,
|
||||
action: "redeem referral code",
|
||||
handler: async (req, res) => {
|
||||
const { orgId, env, logtail: logger, db } = req;
|
||||
const { code, customer_id: customerId } = req.body;
|
||||
|
||||
// 1. Get redeemed by customer, and referral code
|
||||
const [customer, referralCode, org] = await Promise.all([
|
||||
CusService.get({
|
||||
db,
|
||||
orgId,
|
||||
env,
|
||||
idOrInternalId: customerId,
|
||||
}),
|
||||
RewardProgramService.getReferralCode({
|
||||
db,
|
||||
orgId,
|
||||
env,
|
||||
code,
|
||||
withRewardProgram: true,
|
||||
}),
|
||||
OrgService.getFromReq(req),
|
||||
]);
|
||||
|
||||
if (!customer) {
|
||||
throw new RecaseError({
|
||||
message: "Customer not found",
|
||||
statusCode: 404,
|
||||
code: ErrCode.CustomerNotFound,
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Check that code has not reached max redemptions
|
||||
const redemptionCount = await RewardProgramService.getCodeRedemptionCount(
|
||||
{
|
||||
db,
|
||||
referralCodeId: referralCode.id,
|
||||
}
|
||||
);
|
||||
|
||||
if (
|
||||
referralCode.reward_program.max_redemptions &&
|
||||
redemptionCount >= referralCode.reward_program.max_redemptions
|
||||
) {
|
||||
throw new RecaseError({
|
||||
message: "Referral code has reached max redemptions",
|
||||
statusCode: 400,
|
||||
code: ErrCode.ReferralCodeMaxRedemptionsReached,
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Check that customer has not already redeemed a code in this referral program
|
||||
const existingRedemptions = await RewardRedemptionService.getByCustomer({
|
||||
db,
|
||||
internalCustomerId: customer.internal_id,
|
||||
internalRewardProgramId: referralCode.internal_reward_program_id,
|
||||
});
|
||||
|
||||
if (existingRedemptions.length > 0) {
|
||||
throw new RecaseError({
|
||||
message: `Customer ${customer.id} has already redeemed a code in this referral program`,
|
||||
statusCode: 400,
|
||||
code: ErrCode.CustomerAlreadyRedeemedReferralCode,
|
||||
});
|
||||
}
|
||||
|
||||
// Don't let customer redeem their own code
|
||||
const codeCustomer = await CusService.getByInternalId({
|
||||
db: req.db,
|
||||
internalId: referralCode.internal_customer_id,
|
||||
});
|
||||
|
||||
if (!codeCustomer) {
|
||||
throw new RecaseError({
|
||||
message: "Referral code customer not found",
|
||||
statusCode: 404,
|
||||
code: ErrCode.CustomerNotFound,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
codeCustomer.id === customer.id ||
|
||||
(notNullish(codeCustomer.fingerprint) &&
|
||||
codeCustomer.fingerprint === customer.fingerprint)
|
||||
) {
|
||||
throw new RecaseError({
|
||||
message: "Customer cannot redeem their own code",
|
||||
statusCode: 400,
|
||||
code: ErrCode.CustomerCannotRedeemOwnCode,
|
||||
});
|
||||
}
|
||||
|
||||
// 4. Insert redemption into db
|
||||
let redemption: RewardRedemption = {
|
||||
id: generateId("rr"),
|
||||
referral_code_id: referralCode.id,
|
||||
internal_customer_id: customer.internal_id, // redeemed by customer
|
||||
internal_reward_program_id: referralCode.internal_reward_program_id,
|
||||
created_at: Date.now(),
|
||||
triggered:
|
||||
referralCode.reward_program.when ===
|
||||
RewardTriggerEvent.CustomerCreation,
|
||||
applied: false,
|
||||
updated_at: Date.now(),
|
||||
};
|
||||
|
||||
redemption = await RewardRedemptionService.insert({
|
||||
db,
|
||||
rewardRedemption: redemption,
|
||||
});
|
||||
|
||||
// 5. If reward trigger when is immediate:
|
||||
const { reward_program } = referralCode;
|
||||
const redeemRewardNow =
|
||||
referralCode.reward_program.when ===
|
||||
RewardTriggerEvent.CustomerCreation;
|
||||
|
||||
if (redeemRewardNow) {
|
||||
const reward = await RewardService.get({
|
||||
db,
|
||||
orgId,
|
||||
env,
|
||||
idOrInternalId: reward_program.internal_reward_id,
|
||||
});
|
||||
|
||||
if (!reward) {
|
||||
throw new RecaseError({
|
||||
message: `Reward ${reward_program.internal_reward_id} not found`,
|
||||
statusCode: 404,
|
||||
code: ErrCode.RewardNotFound,
|
||||
});
|
||||
}
|
||||
|
||||
const rewardCat = getRewardCat(reward);
|
||||
if (rewardCat === RewardCategory.FreeProduct) {
|
||||
await triggerFreeProduct({
|
||||
req: parseReqForAction(req) as ExtendedRequest,
|
||||
db,
|
||||
referralCode,
|
||||
redeemer: customer,
|
||||
rewardProgram: reward_program,
|
||||
org,
|
||||
env,
|
||||
logger,
|
||||
redemption,
|
||||
});
|
||||
} else {
|
||||
await triggerRedemption({
|
||||
db,
|
||||
referralCode,
|
||||
org,
|
||||
env,
|
||||
logger,
|
||||
reward,
|
||||
redemption,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return res.status(200).json({
|
||||
id: redemption.id,
|
||||
customer_id: customer.id,
|
||||
reward_id: reward_program.reward.id,
|
||||
referrer: {
|
||||
id: codeCustomer.id,
|
||||
name: codeCustomer.name,
|
||||
email: codeCustomer.email,
|
||||
created_at: codeCustomer.created_at,
|
||||
},
|
||||
redeemer: {
|
||||
id: customer.id,
|
||||
name: customer.name,
|
||||
email: customer.email,
|
||||
created_at: customer.created_at,
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// res.status(200).json({
|
||||
// id: redemption.id,
|
||||
// customer_id: customer.id,
|
||||
// reward_id: reward_program.reward.id,
|
||||
// referrer: {
|
||||
// id: codeCustomer.id,
|
||||
// name: codeCustomer.name,
|
||||
// email: codeCustomer.email,
|
||||
// created_at: codeCustomer.created_at,
|
||||
// code: applications.referrer.cause,
|
||||
// },
|
||||
// redeemer: {
|
||||
// code: applications.redeemer.cause,
|
||||
// ...applications.redeemer.meta,
|
||||
// },
|
||||
// });
|
||||
@@ -0,0 +1,3 @@
|
||||
export { default as handleGetRedemption } from "./handleGetRedemption.js";
|
||||
export { default as handleGetReferralCode } from "./handleGetReferralCode.js";
|
||||
export { default as handleRedeemReferral } from "./handleRedeemReferral.js";
|
||||
@@ -0,0 +1,85 @@
|
||||
import {
|
||||
CreateRewardProgram,
|
||||
ErrCode,
|
||||
RewardTriggerEvent,
|
||||
} from "@autumn/shared";
|
||||
import { RewardProgramService } from "@/internal/rewards/RewardProgramService.js";
|
||||
import { constructRewardProgram } from "@/internal/rewards/rewardTriggerUtils.js";
|
||||
import RecaseError from "@/utils/errorUtils.js";
|
||||
import { nullish } from "@/utils/genUtils.js";
|
||||
import { routeHandler } from "@/utils/routerUtils.js";
|
||||
|
||||
export default async (req: any, res: any) =>
|
||||
routeHandler({
|
||||
req,
|
||||
res,
|
||||
action: "create reward trigger",
|
||||
handler: async (req, res) => {
|
||||
const { orgId, env, db } = req;
|
||||
const body = req.body;
|
||||
|
||||
if (!body.internal_reward_id) {
|
||||
throw new RecaseError({
|
||||
message: "Please select a reward to link this program to",
|
||||
code: ErrCode.InvalidRequest,
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
if (!body.id) {
|
||||
throw new RecaseError({
|
||||
message: "Please give this program an ID",
|
||||
code: ErrCode.InvalidRequest,
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const existingProgram = await RewardProgramService.get({
|
||||
db,
|
||||
id: body.id,
|
||||
orgId,
|
||||
env,
|
||||
});
|
||||
|
||||
if (existingProgram) {
|
||||
throw new RecaseError({
|
||||
message: `Program with ID ${body.id} already exists`,
|
||||
code: ErrCode.InvalidRequest,
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const rewardProgram = constructRewardProgram({
|
||||
rewardProgramData: CreateRewardProgram.parse(req.body),
|
||||
orgId,
|
||||
env,
|
||||
});
|
||||
|
||||
// Fetch reward ID
|
||||
// let reward = await RewardService.get({
|
||||
// db,
|
||||
// id: rewardProgram.internal_reward_id,
|
||||
// orgId,
|
||||
// env,
|
||||
// });
|
||||
|
||||
if (
|
||||
rewardProgram.when === RewardTriggerEvent.Checkout &&
|
||||
(nullish(rewardProgram.product_ids) ||
|
||||
rewardProgram.product_ids!.length === 0)
|
||||
) {
|
||||
throw new RecaseError({
|
||||
message: "If redeem on checkout, must specify at least one product",
|
||||
code: ErrCode.InvalidRequest,
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const createdRewardProgram = await RewardProgramService.create({
|
||||
db,
|
||||
data: rewardProgram,
|
||||
});
|
||||
|
||||
return res.status(200).json(createdRewardProgram);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import {
|
||||
CreateRewardProgram,
|
||||
ErrCode,
|
||||
RewardTriggerEvent,
|
||||
} from "@autumn/shared";
|
||||
import { RewardProgramService } from "@/internal/rewards/RewardProgramService.js";
|
||||
import { constructRewardProgram } from "@/internal/rewards/rewardTriggerUtils.js";
|
||||
import RecaseError from "@/utils/errorUtils.js";
|
||||
import { nullish } from "@/utils/genUtils.js";
|
||||
import { routeHandler } from "@/utils/routerUtils.js";
|
||||
|
||||
export default async (req: any, res: any) =>
|
||||
routeHandler({
|
||||
req,
|
||||
res,
|
||||
action: "create trigger",
|
||||
handler: async (req, res) => {
|
||||
const { orgId, env, db } = req;
|
||||
const body = req.body;
|
||||
|
||||
if (!body.internal_reward_id) {
|
||||
throw new RecaseError({
|
||||
message: "Please select a reward to link this program to",
|
||||
code: ErrCode.InvalidRequest,
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
if (!body.id) {
|
||||
throw new RecaseError({
|
||||
message: "Please give this program an ID",
|
||||
code: ErrCode.InvalidRequest,
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const existingProgram = await RewardProgramService.get({
|
||||
db,
|
||||
id: body.id,
|
||||
orgId,
|
||||
env,
|
||||
});
|
||||
|
||||
if (existingProgram) {
|
||||
throw new RecaseError({
|
||||
message: `Program with ID ${body.id} already exists`,
|
||||
code: ErrCode.InvalidRequest,
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const rewardProgram = constructRewardProgram({
|
||||
rewardProgramData: CreateRewardProgram.parse(req.body),
|
||||
orgId,
|
||||
env,
|
||||
});
|
||||
|
||||
// Fetch reward ID
|
||||
// let reward = await RewardService.get({
|
||||
// db,
|
||||
// id: rewardProgram.internal_reward_id,
|
||||
// orgId,
|
||||
// env,
|
||||
// });
|
||||
|
||||
if (
|
||||
rewardProgram.when === RewardTriggerEvent.Checkout &&
|
||||
(nullish(rewardProgram.product_ids) ||
|
||||
rewardProgram.product_ids!.length === 0)
|
||||
) {
|
||||
throw new RecaseError({
|
||||
message: "If redeem on checkout, must specify at least one product",
|
||||
code: ErrCode.InvalidRequest,
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const createdRewardProgram = await RewardProgramService.create({
|
||||
db,
|
||||
data: rewardProgram,
|
||||
});
|
||||
|
||||
return res.status(200).json(createdRewardProgram);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import { RewardProgramService } from "@/internal/rewards/RewardProgramService.js";
|
||||
import { routeHandler } from "@/utils/routerUtils.js";
|
||||
|
||||
export default async (req: any, res: any) =>
|
||||
routeHandler({
|
||||
req,
|
||||
res,
|
||||
action: "delete reward scheme",
|
||||
handler: async (req, res) => {
|
||||
const { orgId, env, db } = req;
|
||||
const { id } = req.params;
|
||||
|
||||
const rewardProgram = await RewardProgramService.delete({
|
||||
db,
|
||||
id,
|
||||
orgId,
|
||||
env,
|
||||
});
|
||||
|
||||
return res.status(200).json(rewardProgram);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
export { default as handleCreateRewardProgram } from "./handleCreateRewardProgram.js";
|
||||
export { default as handleCreateTrigger } from "./handleCreateTrigger.js";
|
||||
export { default as handleDeleteRewardProgram } from "./handleDeleteRewardProgram.js";
|
||||
@@ -0,0 +1,103 @@
|
||||
import {
|
||||
CreateRewardSchema,
|
||||
isFixedPrice,
|
||||
RewardCategory,
|
||||
} from "@autumn/shared";
|
||||
import { createStripeCoupon } from "@/external/stripe/stripeCouponUtils/stripeCouponUtils.js";
|
||||
import { OrgService } from "@/internal/orgs/OrgService.js";
|
||||
import { ProductService } from "@/internal/products/ProductService.js";
|
||||
import { PriceService } from "@/internal/products/prices/PriceService.js";
|
||||
import { pricesOnlyOneOff } from "@/internal/products/prices/priceUtils.js";
|
||||
import { isFreeProduct } from "@/internal/products/productUtils.js";
|
||||
import { RewardService } from "@/internal/rewards/RewardService.js";
|
||||
import {
|
||||
constructReward,
|
||||
getRewardCat,
|
||||
initRewardStripePrices,
|
||||
} from "@/internal/rewards/rewardUtils.js";
|
||||
import { routeHandler } from "@/utils/routerUtils.js";
|
||||
|
||||
export default async (req: any, res: any) =>
|
||||
routeHandler({
|
||||
req,
|
||||
res,
|
||||
action: "create coupon",
|
||||
handler: async (req, res) => {
|
||||
const { db, orgId, env, logtail: logger } = req;
|
||||
const rewardBody = req.body;
|
||||
const rewardData = CreateRewardSchema.parse(rewardBody);
|
||||
|
||||
const org = await OrgService.getFromReq(req);
|
||||
|
||||
const newReward = constructReward({
|
||||
reward: rewardData,
|
||||
orgId,
|
||||
env,
|
||||
});
|
||||
|
||||
if (getRewardCat(newReward) === RewardCategory.Discount) {
|
||||
const discountConfig = newReward.discount_config;
|
||||
|
||||
// Get prices for coupon
|
||||
const [prices] = await Promise.all([
|
||||
PriceService.getInIds({
|
||||
db,
|
||||
ids: discountConfig!.price_ids || [],
|
||||
}),
|
||||
]);
|
||||
|
||||
await initRewardStripePrices({
|
||||
db,
|
||||
prices,
|
||||
org,
|
||||
env,
|
||||
logger,
|
||||
});
|
||||
|
||||
await createStripeCoupon({
|
||||
reward: newReward,
|
||||
org,
|
||||
env,
|
||||
prices,
|
||||
logger,
|
||||
legacyVersion: req.query.legacyStripe === "true",
|
||||
});
|
||||
}
|
||||
|
||||
if (getRewardCat(newReward) === RewardCategory.FreeProduct) {
|
||||
// 1. Check if product is paid
|
||||
const fullProduct = await ProductService.getFull({
|
||||
db,
|
||||
idOrInternalId: newReward.free_product_id!,
|
||||
orgId: org.id,
|
||||
env,
|
||||
});
|
||||
|
||||
if (!isFreeProduct(fullProduct.prices)) {
|
||||
// For one-off products, include all prices; for recurring products, only fixed prices
|
||||
const isProductOneOff = pricesOnlyOneOff(fullProduct.prices);
|
||||
const relevantPrices = isProductOneOff
|
||||
? fullProduct.prices // Include all prices for one-off products
|
||||
: fullProduct.prices.filter((price) => isFixedPrice({ price })); // Only fixed prices for recurring products
|
||||
|
||||
await createStripeCoupon({
|
||||
reward: newReward,
|
||||
org,
|
||||
env,
|
||||
prices: relevantPrices.map((price) => ({
|
||||
...price,
|
||||
product: fullProduct,
|
||||
})),
|
||||
logger,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const insertedCoupon = await RewardService.insert({
|
||||
db,
|
||||
data: newReward,
|
||||
});
|
||||
|
||||
res.status(200).json(insertedCoupon);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { ErrCode } from "@autumn/shared";
|
||||
import { createStripeCli } from "@/external/stripe/utils.js";
|
||||
import { OrgService } from "@/internal/orgs/OrgService.js";
|
||||
import { RewardService } from "@/internal/rewards/RewardService.js";
|
||||
import RecaseError from "@/utils/errorUtils.js";
|
||||
import { routeHandler } from "@/utils/routerUtils.js";
|
||||
|
||||
export default async (req: any, res: any) =>
|
||||
routeHandler({
|
||||
req,
|
||||
res,
|
||||
action: "delete reward",
|
||||
handler: async (req, res) => {
|
||||
const { id } = req.params;
|
||||
const { orgId, env, db } = req;
|
||||
|
||||
const org = await OrgService.getFromReq(req);
|
||||
const stripeCli = createStripeCli({
|
||||
org,
|
||||
env,
|
||||
});
|
||||
|
||||
const reward = await RewardService.get({
|
||||
db,
|
||||
idOrInternalId: id,
|
||||
orgId,
|
||||
env,
|
||||
});
|
||||
|
||||
if (!reward) {
|
||||
throw new RecaseError({
|
||||
message: `Reward ${id} not found`,
|
||||
code: ErrCode.InvalidRequest,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
await stripeCli.coupons.del(reward.id);
|
||||
} catch (error) {
|
||||
console.log(`Failed to delete coupon from stripe: ${(error as { message: string }).message}`);
|
||||
}
|
||||
|
||||
await RewardService.delete({
|
||||
db,
|
||||
internalId: reward.internal_id,
|
||||
env,
|
||||
orgId,
|
||||
});
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: "Reward deleted successfully",
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { RewardService } from "@/internal/rewards/RewardService.js";
|
||||
import { routeHandler } from "@/utils/routerUtils.js";
|
||||
|
||||
export default async (req: any, res: any) => routeHandler({
|
||||
req,
|
||||
res,
|
||||
action: "get reward",
|
||||
handler: async (req, res) => {
|
||||
const { id } = req.params;
|
||||
const { orgId, env, db } = req;
|
||||
|
||||
const reward = await RewardService.get({
|
||||
db,
|
||||
idOrInternalId: id,
|
||||
orgId,
|
||||
env,
|
||||
});
|
||||
|
||||
res.status(200).json(reward);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
import { ErrCode, PriceType, RewardCategory } from "@autumn/shared";
|
||||
import { createStripeCoupon } from "@/external/stripe/stripeCouponUtils/stripeCouponUtils.js";
|
||||
import { createStripeCli } from "@/external/stripe/utils.js";
|
||||
import { OrgService } from "@/internal/orgs/OrgService.js";
|
||||
import { ProductService } from "@/internal/products/ProductService.js";
|
||||
import { PriceService } from "@/internal/products/prices/PriceService.js";
|
||||
import { RewardService } from "@/internal/rewards/RewardService.js";
|
||||
import { getRewardCat } from "@/internal/rewards/rewardUtils.js";
|
||||
import RecaseError from "@/utils/errorUtils.js";
|
||||
import { routeHandler } from "@/utils/routerUtils.js";
|
||||
|
||||
export default async (req: any, res: any) =>
|
||||
routeHandler({
|
||||
req,
|
||||
res,
|
||||
action: "update coupon",
|
||||
handler: async (req, res) => {
|
||||
const { internalId } = req.params;
|
||||
const { orgId, env, db, logtail: logger } = req;
|
||||
const rewardBody = req.body;
|
||||
|
||||
const org = await OrgService.getFromReq(req);
|
||||
const stripeCli = createStripeCli({
|
||||
org,
|
||||
env,
|
||||
});
|
||||
|
||||
const reward = await RewardService.get({
|
||||
db,
|
||||
idOrInternalId: internalId,
|
||||
orgId,
|
||||
env,
|
||||
});
|
||||
|
||||
if (!reward) {
|
||||
throw new RecaseError({
|
||||
message: `Reward ${internalId} not found`,
|
||||
code: ErrCode.InvalidRequest,
|
||||
});
|
||||
}
|
||||
|
||||
// Determine prices depending on reward category
|
||||
const rewardCat = getRewardCat(rewardBody);
|
||||
|
||||
let prices: any[] = [];
|
||||
if (rewardCat === RewardCategory.Discount) {
|
||||
const stripePriceIds =
|
||||
rewardBody.discount_config?.price_ids ??
|
||||
reward.discount_config?.price_ids ??
|
||||
[];
|
||||
const byStripeId = await PriceService.getByStripeIds({
|
||||
db,
|
||||
stripePriceIds,
|
||||
});
|
||||
prices = stripePriceIds
|
||||
.map((id: string) => byStripeId[id])
|
||||
.filter(Boolean);
|
||||
} else if (rewardCat === RewardCategory.FreeProduct) {
|
||||
const freeProductId =
|
||||
rewardBody.free_product_id ?? reward.free_product_id;
|
||||
if (freeProductId) {
|
||||
const fullProduct = await ProductService.getFull({
|
||||
db,
|
||||
idOrInternalId: freeProductId,
|
||||
orgId: org.id,
|
||||
env,
|
||||
});
|
||||
prices = fullProduct.prices
|
||||
.map((price) => ({
|
||||
...price,
|
||||
product: fullProduct,
|
||||
}))
|
||||
.filter((x) => x.config?.type === PriceType.Fixed);
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Delete old prices from stripe
|
||||
try {
|
||||
await stripeCli.coupons.del(reward.id);
|
||||
await stripeCli.coupons.del(reward.internal_id);
|
||||
} catch (_) {
|
||||
// console.log(`Failed to delete coupon from stripe: ${error.message}`);
|
||||
}
|
||||
|
||||
if (
|
||||
rewardCat === RewardCategory.Discount ||
|
||||
(rewardCat === RewardCategory.FreeProduct && prices.length > 0)
|
||||
) {
|
||||
await createStripeCoupon({
|
||||
reward: rewardBody,
|
||||
org,
|
||||
env,
|
||||
prices,
|
||||
logger,
|
||||
legacyVersion: req.query.legacyStripe === "true",
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Update coupon in db
|
||||
const updatedCoupon = await RewardService.update({
|
||||
db,
|
||||
internalId: reward.internal_id,
|
||||
env,
|
||||
orgId,
|
||||
update: rewardBody,
|
||||
});
|
||||
|
||||
res.status(200).json(updatedCoupon);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
import handleCreateCoupon from "./handleCreateCoupon.js";
|
||||
import handleDeleteCoupon from "./handleDeleteCoupon.js";
|
||||
import handleGetCoupon from "./handleGetCoupon.js";
|
||||
import handleUpdateCoupon from "./handleUpdateCoupon.js";
|
||||
|
||||
export { handleCreateCoupon, handleDeleteCoupon, handleGetCoupon, handleUpdateCoupon };
|
||||
@@ -1,301 +1,17 @@
|
||||
import { CusService } from "@/internal/customers/CusService.js";
|
||||
import express, { type Router } from "express";
|
||||
import {
|
||||
generateReferralCode,
|
||||
triggerFreeProduct,
|
||||
triggerRedemption,
|
||||
} from "@/internal/rewards/referralUtils.js";
|
||||
import { RewardRedemptionService } from "@/internal/rewards/RewardRedemptionService.js";
|
||||
|
||||
import RecaseError from "@/utils/errorUtils.js";
|
||||
import { generateId, notNullish } from "@/utils/genUtils.js";
|
||||
import { routeHandler } from "@/utils/routerUtils.js";
|
||||
import { ErrCode, RewardCategory, RewardTriggerEvent } from "@autumn/shared";
|
||||
import express, { Router } from "express";
|
||||
import { RewardRedemption } from "@autumn/shared";
|
||||
import { OrgService } from "@/internal/orgs/OrgService.js";
|
||||
import { RewardProgramService } from "@/internal/rewards/RewardProgramService.js";
|
||||
import { getRewardCat } from "@/internal/rewards/rewardUtils.js";
|
||||
import { RewardService } from "@/internal/rewards/RewardService.js";
|
||||
import { parseReqForAction } from "@/internal/analytics/actionUtils.js";
|
||||
import { ExtendedRequest } from "@/utils/models/Request.js";
|
||||
handleGetRedemption,
|
||||
handleGetReferralCode,
|
||||
handleRedeemReferral,
|
||||
} from "./handlers/referrals/index.js";
|
||||
|
||||
export const referralRouter: Router = express.Router();
|
||||
|
||||
// 1. Get referral code
|
||||
referralRouter.post("/code", (req, res) =>
|
||||
routeHandler({
|
||||
req,
|
||||
res,
|
||||
action: "get referral code",
|
||||
handler: async (req: any, res: any) => {
|
||||
const { orgId, env, logtail: logger, db } = req;
|
||||
const { program_id: rewardProgramId, customer_id: customerId } = req.body;
|
||||
referralRouter.post("/code", handleGetReferralCode);
|
||||
|
||||
let [rewardProgram, customer] = await Promise.all([
|
||||
RewardProgramService.get({
|
||||
db,
|
||||
id: rewardProgramId,
|
||||
orgId,
|
||||
env,
|
||||
errorIfNotFound: true,
|
||||
}),
|
||||
CusService.get({
|
||||
db: req.db,
|
||||
orgId,
|
||||
env,
|
||||
idOrInternalId: customerId,
|
||||
}),
|
||||
]);
|
||||
|
||||
if (!customer) {
|
||||
throw new RecaseError({
|
||||
message: "Customer not found",
|
||||
statusCode: 404,
|
||||
code: ErrCode.CustomerNotFound,
|
||||
});
|
||||
}
|
||||
|
||||
if (!rewardProgram) {
|
||||
throw new RecaseError({
|
||||
message: "Reward program not found",
|
||||
statusCode: 404,
|
||||
code: ErrCode.RewardProgramNotFound,
|
||||
});
|
||||
}
|
||||
|
||||
// Get referral code by customer and reward trigger
|
||||
let referralCode =
|
||||
await RewardProgramService.getCodeByCustomerAndRewardProgram({
|
||||
db,
|
||||
orgId,
|
||||
env,
|
||||
internalCustomerId: customer.internal_id,
|
||||
internalRewardProgramId: rewardProgram.internal_id,
|
||||
});
|
||||
|
||||
if (!referralCode) {
|
||||
const code = generateReferralCode();
|
||||
|
||||
referralCode = {
|
||||
code,
|
||||
org_id: orgId,
|
||||
env,
|
||||
internal_customer_id: customer.internal_id,
|
||||
internal_reward_program_id: rewardProgram.internal_id,
|
||||
id: generateId("rc"),
|
||||
created_at: Date.now(),
|
||||
};
|
||||
|
||||
referralCode = await RewardProgramService.createReferralCode({
|
||||
db,
|
||||
data: referralCode,
|
||||
});
|
||||
}
|
||||
|
||||
res.status(200).json({
|
||||
code: referralCode.code,
|
||||
customer_id: customer.id,
|
||||
created_at: referralCode.created_at,
|
||||
});
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
referralRouter.post("/redeem", (req, res) =>
|
||||
routeHandler({
|
||||
req,
|
||||
res,
|
||||
action: "redeem referral code",
|
||||
handler: async (req: any, res: any) => {
|
||||
const { orgId, env, logtail: logger, db } = req;
|
||||
// const { referral_id: rewardTriggerId } = req.params;
|
||||
const { code, customer_id: customerId } = req.body;
|
||||
|
||||
// 1. Get redeemed by customer, and referral code
|
||||
let [customer, referralCode, org] = await Promise.all([
|
||||
CusService.get({
|
||||
db,
|
||||
orgId,
|
||||
env,
|
||||
idOrInternalId: customerId,
|
||||
}),
|
||||
RewardProgramService.getReferralCode({
|
||||
db,
|
||||
orgId,
|
||||
env,
|
||||
code,
|
||||
withRewardProgram: true,
|
||||
}),
|
||||
OrgService.getFromReq(req),
|
||||
]);
|
||||
|
||||
if (!customer) {
|
||||
throw new RecaseError({
|
||||
message: "Customer not found",
|
||||
statusCode: 404,
|
||||
code: ErrCode.CustomerNotFound,
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Check that code has not reached max redemptions
|
||||
let redemptionCount = await RewardProgramService.getCodeRedemptionCount({
|
||||
db,
|
||||
referralCodeId: referralCode.id,
|
||||
});
|
||||
|
||||
if (
|
||||
referralCode.reward_program.max_redemptions &&
|
||||
redemptionCount >= referralCode.reward_program.max_redemptions
|
||||
) {
|
||||
throw new RecaseError({
|
||||
message: "Referral code has reached max redemptions",
|
||||
statusCode: 400,
|
||||
code: ErrCode.ReferralCodeMaxRedemptionsReached,
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Check that customer has not already redeemed a code in this referral program
|
||||
let existingRedemptions = await RewardRedemptionService.getByCustomer({
|
||||
db,
|
||||
internalCustomerId: customer.internal_id,
|
||||
internalRewardProgramId: referralCode.internal_reward_program_id,
|
||||
});
|
||||
|
||||
if (existingRedemptions.length > 0) {
|
||||
throw new RecaseError({
|
||||
message: `Customer ${customer.id} has already redeemed a code in this referral program`,
|
||||
statusCode: 400,
|
||||
code: ErrCode.CustomerAlreadyRedeemedReferralCode,
|
||||
});
|
||||
}
|
||||
|
||||
// Don't let customer redeem their own code
|
||||
let codeCustomer = await CusService.getByInternalId({
|
||||
db: req.db,
|
||||
internalId: referralCode.internal_customer_id,
|
||||
});
|
||||
|
||||
if (!codeCustomer) {
|
||||
throw new RecaseError({
|
||||
message: "Referral code customer not found",
|
||||
statusCode: 404,
|
||||
code: ErrCode.CustomerNotFound,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
codeCustomer.id === customer.id ||
|
||||
(notNullish(codeCustomer.fingerprint) &&
|
||||
codeCustomer.fingerprint === customer.fingerprint)
|
||||
) {
|
||||
throw new RecaseError({
|
||||
message: "Customer cannot redeem their own code",
|
||||
statusCode: 400,
|
||||
code: ErrCode.CustomerCannotRedeemOwnCode,
|
||||
});
|
||||
}
|
||||
|
||||
// 4. Insert redemption into db
|
||||
let redemption: RewardRedemption = {
|
||||
id: generateId("rr"),
|
||||
referral_code_id: referralCode.id,
|
||||
internal_customer_id: customer.internal_id, // redeemed by customer
|
||||
internal_reward_program_id: referralCode.internal_reward_program_id,
|
||||
created_at: Date.now(),
|
||||
triggered:
|
||||
referralCode.reward_program.when ===
|
||||
RewardTriggerEvent.CustomerCreation,
|
||||
applied: false,
|
||||
updated_at: Date.now(),
|
||||
};
|
||||
|
||||
redemption = await RewardRedemptionService.insert({
|
||||
db,
|
||||
rewardRedemption: redemption,
|
||||
});
|
||||
|
||||
// 5. If reward trigger when is immediate:
|
||||
let { reward_program } = referralCode;
|
||||
|
||||
if (
|
||||
referralCode.reward_program.when === RewardTriggerEvent.CustomerCreation
|
||||
) {
|
||||
const reward = await RewardService.get({
|
||||
db,
|
||||
orgId,
|
||||
env,
|
||||
idOrInternalId: reward_program.internal_reward_id,
|
||||
});
|
||||
|
||||
if (!reward) {
|
||||
throw new RecaseError({
|
||||
message: `Reward ${reward_program.internal_reward_id} not found`,
|
||||
statusCode: 404,
|
||||
code: ErrCode.RewardNotFound,
|
||||
});
|
||||
}
|
||||
|
||||
let rewardCat = getRewardCat(reward);
|
||||
if (rewardCat === RewardCategory.FreeProduct) {
|
||||
await triggerFreeProduct({
|
||||
req: parseReqForAction(req) as ExtendedRequest,
|
||||
db,
|
||||
referralCode,
|
||||
redeemer: customer,
|
||||
rewardProgram: reward_program,
|
||||
org,
|
||||
env,
|
||||
logger,
|
||||
redemption,
|
||||
});
|
||||
} else {
|
||||
await triggerRedemption({
|
||||
db,
|
||||
referralCode,
|
||||
org,
|
||||
env,
|
||||
logger,
|
||||
reward,
|
||||
redemption,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Add coupon to customer?
|
||||
res.status(200).json({
|
||||
id: redemption.id,
|
||||
customer_id: customer.id,
|
||||
// triggered: redemption?.applied,
|
||||
// applied: redemption?.applied,
|
||||
reward_id: reward_program.reward.id,
|
||||
referrer: {
|
||||
id: codeCustomer.id,
|
||||
name: codeCustomer.name,
|
||||
email: codeCustomer.email,
|
||||
created_at: codeCustomer.created_at,
|
||||
},
|
||||
});
|
||||
},
|
||||
})
|
||||
);
|
||||
referralRouter.post("/redeem", handleRedeemReferral);
|
||||
|
||||
export const redemptionRouter: Router = express.Router();
|
||||
|
||||
redemptionRouter.get("/:redemptionId", (req, res) =>
|
||||
routeHandler({
|
||||
req,
|
||||
res,
|
||||
action: "get redemption by id",
|
||||
handler: async (req: any, res: any) => {
|
||||
const { db } = req;
|
||||
const { redemptionId } = req.params;
|
||||
|
||||
let redemption = await RewardRedemptionService.getById({
|
||||
db,
|
||||
id: redemptionId,
|
||||
});
|
||||
|
||||
res.status(200).json(redemption);
|
||||
},
|
||||
})
|
||||
);
|
||||
redemptionRouter.get("/:redemptionId", handleGetRedemption);
|
||||
|
||||
@@ -1,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);
|
||||
@@ -1,239 +1,14 @@
|
||||
import express from "express";
|
||||
import { CreateRewardSchema, ErrCode, RewardCategory } from "@autumn/shared";
|
||||
import { Router } from "express";
|
||||
import RecaseError, { handleRequestError } from "@/utils/errorUtils.js";
|
||||
import { createStripeCli } from "@/external/stripe/utils.js";
|
||||
import { OrgService } from "@/internal/orgs/OrgService.js";
|
||||
|
||||
import { createStripeCoupon } from "@/external/stripe/stripeCouponUtils/stripeCouponUtils.js";
|
||||
import { RewardService } from "@/internal/rewards/RewardService.js";
|
||||
import { PriceService } from "@/internal/products/prices/PriceService.js";
|
||||
import { EntitlementService } from "@/internal/products/entitlements/EntitlementService.js";
|
||||
import {
|
||||
constructReward,
|
||||
getRewardCat,
|
||||
initRewardStripePrices,
|
||||
} from "@/internal/rewards/rewardUtils.js";
|
||||
import { notNullish } from "@/utils/genUtils.js";
|
||||
import express, { type Router } from "express";
|
||||
import { handleCreateCoupon, handleDeleteCoupon, handleGetCoupon, handleUpdateCoupon } from "./handlers/rewards/index.js";
|
||||
|
||||
const rewardRouter: Router = express.Router();
|
||||
|
||||
rewardRouter.post("", async (req: any, res: any) => {
|
||||
try {
|
||||
const { db, orgId, env, logtail: logger } = req;
|
||||
const rewardBody = req.body;
|
||||
const rewardData = CreateRewardSchema.parse(rewardBody);
|
||||
rewardRouter.post("", handleCreateCoupon);
|
||||
|
||||
const org = await OrgService.getFromReq(req);
|
||||
rewardRouter.delete("/:id", handleDeleteCoupon);
|
||||
|
||||
const newReward = constructReward({
|
||||
reward: rewardData,
|
||||
orgId,
|
||||
env,
|
||||
// internalId: rewardBody.internal_id,
|
||||
});
|
||||
rewardRouter.post("/:internalId", handleUpdateCoupon);
|
||||
|
||||
if (getRewardCat(newReward) === RewardCategory.Discount) {
|
||||
const stripeCli = createStripeCli({
|
||||
org,
|
||||
env,
|
||||
});
|
||||
rewardRouter.get("/:id", handleGetCoupon);
|
||||
|
||||
let discountConfig = newReward.discount_config;
|
||||
|
||||
// Get prices for coupon
|
||||
const [prices, entitlements] = await Promise.all([
|
||||
PriceService.getInIds({
|
||||
db,
|
||||
ids: discountConfig!.price_ids || [],
|
||||
}),
|
||||
EntitlementService.getByOrg({
|
||||
db,
|
||||
orgId,
|
||||
env,
|
||||
}),
|
||||
]);
|
||||
|
||||
await initRewardStripePrices({
|
||||
db,
|
||||
prices,
|
||||
org,
|
||||
env,
|
||||
logger,
|
||||
});
|
||||
|
||||
await createStripeCoupon({
|
||||
reward: newReward,
|
||||
org,
|
||||
env,
|
||||
prices,
|
||||
logger,
|
||||
legacyVersion: req.query.legacyStripe === "true",
|
||||
});
|
||||
}
|
||||
|
||||
const insertedCoupon = await RewardService.insert({
|
||||
db,
|
||||
data: newReward,
|
||||
});
|
||||
|
||||
res.status(200).json(insertedCoupon);
|
||||
} catch (error) {
|
||||
handleRequestError({
|
||||
error,
|
||||
res,
|
||||
req,
|
||||
action: "create coupon",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
rewardRouter.delete("/:id", async (req: any, res: any) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { orgId, env, db } = req;
|
||||
|
||||
const org = await OrgService.getFromReq(req);
|
||||
const stripeCli = createStripeCli({
|
||||
org,
|
||||
env,
|
||||
});
|
||||
|
||||
let reward = await RewardService.get({
|
||||
db,
|
||||
idOrInternalId: id,
|
||||
orgId,
|
||||
env,
|
||||
});
|
||||
|
||||
if (!reward) {
|
||||
throw new RecaseError({
|
||||
message: `Reward ${id} not found`,
|
||||
code: ErrCode.InvalidRequest,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
await stripeCli.coupons.del(reward.id);
|
||||
} catch (error: any) {
|
||||
console.log(`Failed to delete coupon from stripe: ${error.message}`);
|
||||
}
|
||||
|
||||
await RewardService.delete({
|
||||
db,
|
||||
internalId: reward.internal_id,
|
||||
env,
|
||||
orgId,
|
||||
});
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: "Reward deleted successfully",
|
||||
});
|
||||
} catch (error) {
|
||||
handleRequestError({
|
||||
error,
|
||||
res,
|
||||
req,
|
||||
action: "delete coupon",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
rewardRouter.post("/:internalId", async (req: any, res: any) => {
|
||||
try {
|
||||
const { internalId } = req.params;
|
||||
const { orgId, env, db, logtail: logger } = req;
|
||||
const rewardBody = req.body;
|
||||
|
||||
const org = await OrgService.getFromReq(req);
|
||||
const stripeCli = createStripeCli({
|
||||
org,
|
||||
env,
|
||||
});
|
||||
|
||||
const reward = await RewardService.get({
|
||||
db,
|
||||
idOrInternalId: internalId,
|
||||
orgId,
|
||||
env,
|
||||
});
|
||||
|
||||
if (!reward) {
|
||||
throw new RecaseError({
|
||||
message: `Reward ${internalId} not found`,
|
||||
code: ErrCode.InvalidRequest,
|
||||
});
|
||||
}
|
||||
|
||||
const prices = await PriceService.getInIds({
|
||||
db,
|
||||
ids: notNullish(rewardBody.price_ids)
|
||||
? rewardBody.price_ids
|
||||
: reward.discount_config?.price_ids,
|
||||
});
|
||||
|
||||
// 1. Delete old prices from stripe
|
||||
try {
|
||||
await stripeCli.coupons.del(reward.id);
|
||||
await stripeCli.coupons.del(reward.internal_id);
|
||||
} catch (error) {
|
||||
// console.log(`Failed to delete coupon from stripe: ${error.message}`);
|
||||
}
|
||||
|
||||
let rewardCat = getRewardCat(rewardBody);
|
||||
if (rewardCat == RewardCategory.Discount) {
|
||||
await createStripeCoupon({
|
||||
reward: rewardBody,
|
||||
org,
|
||||
env,
|
||||
prices,
|
||||
logger,
|
||||
legacyVersion: req.query.legacyStripe === "true",
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Update coupon in db
|
||||
const updatedCoupon = await RewardService.update({
|
||||
db,
|
||||
internalId: reward.internal_id,
|
||||
env,
|
||||
orgId,
|
||||
update: rewardBody,
|
||||
});
|
||||
|
||||
res.status(200).json(updatedCoupon);
|
||||
} catch (error) {
|
||||
handleRequestError({
|
||||
error,
|
||||
res,
|
||||
req,
|
||||
action: "update coupon",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
rewardRouter.get("/:id", async (req: any, res: any) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { orgId, env, db } = req;
|
||||
|
||||
const reward = await RewardService.get({
|
||||
db,
|
||||
idOrInternalId: id,
|
||||
orgId,
|
||||
env,
|
||||
});
|
||||
|
||||
res.status(200).json(reward);
|
||||
} catch (error) {
|
||||
handleRequestError({
|
||||
error,
|
||||
res,
|
||||
req,
|
||||
action: "get reward",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export default rewardRouter;
|
||||
export default rewardRouter;
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
import RecaseError from "@/utils/errorUtils.js";
|
||||
import { ErrCode } from "@/errors/errCodes.js";
|
||||
import { StatusCodes } from "http-status-codes";
|
||||
import { and, eq, or, sql } from "drizzle-orm";
|
||||
import { and, eq, ilike, or, sql } from "drizzle-orm";
|
||||
import { DrizzleCli } from "@/db/initDrizzle.js";
|
||||
import { getFullCusQuery } from "./getFullCusQuery.js";
|
||||
import { trace } from "@opentelemetry/api";
|
||||
@@ -151,7 +151,7 @@ export class CusService {
|
||||
}) {
|
||||
const customer = await db.query.customers.findMany({
|
||||
where: and(
|
||||
eq(customers.email, email),
|
||||
ilike(customers.email, email),
|
||||
eq(customers.org_id, orgId),
|
||||
eq(customers.env, env)
|
||||
),
|
||||
|
||||
@@ -1,41 +1,39 @@
|
||||
import {
|
||||
CusProductStatus,
|
||||
Price,
|
||||
ProcessorType,
|
||||
CustomerEntitlement,
|
||||
CusProduct,
|
||||
FeatureOptions,
|
||||
FreeTrial,
|
||||
type APIVersion,
|
||||
CollectionMethod,
|
||||
FullCusProduct,
|
||||
APIVersion,
|
||||
InsertReplaceable,
|
||||
ProductOptions,
|
||||
type CusProduct,
|
||||
CusProductStatus,
|
||||
type Customer,
|
||||
type CustomerEntitlement,
|
||||
type CustomerPrice,
|
||||
type FeatureOptions,
|
||||
type FreeTrial,
|
||||
type FullCusProduct,
|
||||
type FullProduct,
|
||||
type InsertReplaceable,
|
||||
type Price,
|
||||
ProcessorType,
|
||||
type ProductOptions,
|
||||
} from "@autumn/shared";
|
||||
import { generateId, notNullish, nullish } from "@/utils/genUtils.js";
|
||||
|
||||
import { Customer } from "@autumn/shared";
|
||||
import { FullProduct } from "@autumn/shared";
|
||||
import { getEntOptions } from "@/internal/products/prices/priceUtils.js";
|
||||
import { CustomerPrice } from "@autumn/shared";
|
||||
import { CusProductService } from "../cusProducts/CusProductService.js";
|
||||
import { InsertCusProductParams } from "../cusProducts/AttachParams.js";
|
||||
import { freeTrialToStripeTimestamp } from "@/internal/products/free-trials/freeTrialUtils.js";
|
||||
import { getEntRelatedPrice } from "@/internal/products/entitlements/entitlementUtils.js";
|
||||
|
||||
import { getExistingCusProducts } from "../cusProducts/cusProductUtils/getExistingCusProducts.js";
|
||||
import { isFreeProduct, isOneOff } from "@/internal/products/productUtils.js";
|
||||
import { searchCusProducts } from "@/internal/customers/cusProducts/cusProductUtils.js";
|
||||
import { updateOneTimeCusProduct } from "./createOneTimeCusProduct.js";
|
||||
import { initCusEntitlement } from "./initCusEnt.js";
|
||||
import type { DrizzleCli } from "@/db/initDrizzle.js";
|
||||
import { addProductsUpdatedWebhookTask } from "@/internal/analytics/handlers/handleProductsUpdated.js";
|
||||
import { DrizzleCli } from "@/db/initDrizzle.js";
|
||||
import { searchCusProducts } from "@/internal/customers/cusProducts/cusProductUtils.js";
|
||||
import { getEntRelatedPrice } from "@/internal/products/entitlements/entitlementUtils.js";
|
||||
import { freeTrialToStripeTimestamp } from "@/internal/products/free-trials/freeTrialUtils.js";
|
||||
import { getEntOptions } from "@/internal/products/prices/priceUtils.js";
|
||||
import { isFreeProduct, isOneOff } from "@/internal/products/productUtils.js";
|
||||
import { generateId, notNullish, nullish } from "@/utils/genUtils.js";
|
||||
import type { InsertCusProductParams } from "../cusProducts/AttachParams.js";
|
||||
import { CusProductService } from "../cusProducts/CusProductService.js";
|
||||
import { CusEntService } from "../cusProducts/cusEnts/CusEntitlementService.js";
|
||||
import { CusPriceService } from "../cusProducts/cusPrices/CusPriceService.js";
|
||||
import { addExistingUsagesToCusEnts } from "../cusProducts/cusEnts/cusEntUtils/getExistingUsage.js";
|
||||
import { RepService } from "../cusProducts/cusEnts/RepService.js";
|
||||
import { getNewProductRollovers } from "../cusProducts/cusEnts/cusRollovers/getNewProductRollovers.js";
|
||||
import { RolloverService } from "../cusProducts/cusEnts/cusRollovers/RolloverService.js";
|
||||
import { RepService } from "../cusProducts/cusEnts/RepService.js";
|
||||
import { CusPriceService } from "../cusProducts/cusPrices/CusPriceService.js";
|
||||
import { getExistingCusProducts } from "../cusProducts/cusProductUtils/getExistingCusProducts.js";
|
||||
import { updateOneTimeCusProduct } from "./createOneTimeCusProduct.js";
|
||||
import { initCusEntitlement } from "./initCusEnt.js";
|
||||
|
||||
export const initCusPrice = ({
|
||||
price,
|
||||
@@ -103,7 +101,7 @@ export const initCusProduct = ({
|
||||
apiVersion?: APIVersion;
|
||||
quantity?: number;
|
||||
}) => {
|
||||
let isFuture = startsAt && startsAt > Date.now();
|
||||
const isFuture = startsAt && startsAt > Date.now();
|
||||
|
||||
let trialEnds = trialEndsAt;
|
||||
if (!trialEndsAt && freeTrial) {
|
||||
@@ -197,7 +195,7 @@ export const expireOrDeleteCusProduct = async ({
|
||||
}) => {
|
||||
// 1. If startsAt
|
||||
if (startsAt && startsAt > Date.now()) {
|
||||
let curScheduledProduct = cusProducts?.find(
|
||||
const curScheduledProduct = cusProducts?.find(
|
||||
(cp) =>
|
||||
cp.product.group === product.group &&
|
||||
cp.status === CusProductStatus.Scheduled &&
|
||||
@@ -213,7 +211,7 @@ export const expireOrDeleteCusProduct = async ({
|
||||
});
|
||||
}
|
||||
} else {
|
||||
let { curMainProduct } = getExistingCusProducts({
|
||||
const { curMainProduct } = getExistingCusProducts({
|
||||
product,
|
||||
cusProducts: cusProducts as FullCusProduct[],
|
||||
internalEntityId,
|
||||
@@ -318,7 +316,7 @@ export const createFullCusProduct = async ({
|
||||
attachParams;
|
||||
|
||||
// Try to get current cus product or set to null...
|
||||
let curCusProduct = await getExistingCusProduct({
|
||||
const curCusProduct = await getExistingCusProduct({
|
||||
db,
|
||||
cusProducts: attachParams.cusProducts,
|
||||
product,
|
||||
@@ -333,7 +331,7 @@ export const createFullCusProduct = async ({
|
||||
trialEndsAt = curCusProduct.trial_ends_at || undefined;
|
||||
}
|
||||
|
||||
let attachReplaceables = attachParams.replaceables || [];
|
||||
const attachReplaceables = attachParams.replaceables || [];
|
||||
|
||||
const existingCusProduct = searchCusProducts({
|
||||
internalProductId: product.internal_id,
|
||||
@@ -388,7 +386,7 @@ export const createFullCusProduct = async ({
|
||||
|
||||
cusEnts.push(cusEnt);
|
||||
|
||||
let newReplaceables_ = attachReplaceables
|
||||
const newReplaceables_ = attachReplaceables
|
||||
.filter((r) => r.ent.id === entitlement.id)
|
||||
.map((r) => ({
|
||||
...r,
|
||||
@@ -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,
|
||||
|
||||
@@ -1,21 +1,22 @@
|
||||
import { freeTrialToStripeTimestamp } from "@/internal/products/free-trials/freeTrialUtils.js";
|
||||
import RecaseError from "@/utils/errorUtils.js";
|
||||
import { ErrCode, Reward, IntervalConfig, AttachConfig } from "@autumn/shared";
|
||||
import Stripe from "stripe";
|
||||
import { type AttachConfig, ErrCode } from "@autumn/shared";
|
||||
import type Stripe from "stripe";
|
||||
import type { DrizzleCli } from "@/db/initDrizzle.js";
|
||||
import { getCusPaymentMethod } from "@/external/stripe/stripeCusUtils.js";
|
||||
import { SubService } from "@/internal/subscriptions/SubService.js";
|
||||
import { generateId } from "@/utils/genUtils.js";
|
||||
import { DrizzleCli } from "@/db/initDrizzle.js";
|
||||
|
||||
import {
|
||||
getLatestPeriodStart,
|
||||
getEarliestPeriodEnd,
|
||||
getLatestPeriodStart,
|
||||
} from "@/external/stripe/stripeSubUtils/convertSubUtils.js";
|
||||
|
||||
import { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js";
|
||||
import { sanitizeSubItems } from "@/external/stripe/stripeSubUtils/getStripeSubItems.js";
|
||||
import { ItemSet } from "@/utils/models/ItemSet.js";
|
||||
import type { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js";
|
||||
import { buildInvoiceMemoFromEntitlements } from "@/internal/invoices/invoiceMemoUtils.js";
|
||||
import {
|
||||
freeTrialToStripeTimestamp,
|
||||
rewardTrialToStripeTimestamp,
|
||||
} from "@/internal/products/free-trials/freeTrialUtils.js";
|
||||
import { SubService } from "@/internal/subscriptions/SubService.js";
|
||||
import RecaseError from "@/utils/errorUtils.js";
|
||||
import { generateId } from "@/utils/genUtils.js";
|
||||
import type { ItemSet } from "@/utils/models/ItemSet.js";
|
||||
|
||||
// Get payment method
|
||||
|
||||
@@ -36,16 +37,17 @@ export const createStripeSub2 = async ({
|
||||
itemSet: ItemSet;
|
||||
logger: any;
|
||||
}) => {
|
||||
const { customer, invoiceOnly, freeTrial, org, now, rewards } = attachParams;
|
||||
const isDefaultTrial = freeTrial && !freeTrial.card_required;
|
||||
const { customer, invoiceOnly, freeTrial, org, now, rewards, rewardTrial } =
|
||||
attachParams;
|
||||
// const isDefaultTrial = freeTrial && !freeTrial.card_required;
|
||||
// let shouldErrorIfNoPm = !invoiceOnly;
|
||||
// if (isDefaultTrial) shouldErrorIfNoPm = false;
|
||||
// if (rewardTrial) shouldErrorIfNoPm = false;
|
||||
|
||||
let shouldErrorIfNoPm = !invoiceOnly;
|
||||
if (isDefaultTrial) shouldErrorIfNoPm = false;
|
||||
|
||||
let paymentMethod = await getCusPaymentMethod({
|
||||
const paymentMethod = await getCusPaymentMethod({
|
||||
stripeCli,
|
||||
stripeId: customer.processor.id,
|
||||
errorIfNone: shouldErrorIfNoPm,
|
||||
errorIfNone: config.requirePaymentMethod,
|
||||
});
|
||||
|
||||
let paymentMethodData = {};
|
||||
@@ -68,7 +70,6 @@ export const createStripeSub2 = async ({
|
||||
items: sanitizeSubItems(subItems),
|
||||
|
||||
billing_mode: { type: "flexible" },
|
||||
trial_end: freeTrialToStripeTimestamp({ freeTrial, now }),
|
||||
payment_behavior: "error_if_incomplete",
|
||||
add_invoice_items: invoiceItems,
|
||||
collection_method: invoiceOnly ? "send_invoice" : "charge_automatically",
|
||||
@@ -80,14 +81,30 @@ export const createStripeSub2 = async ({
|
||||
discounts,
|
||||
expand: ["latest_invoice"],
|
||||
|
||||
trial_settings:
|
||||
freeTrial && !freeTrial.card_required
|
||||
? {
|
||||
end_behavior: {
|
||||
missing_payment_method: "cancel",
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
...{
|
||||
trial_settings:
|
||||
freeTrial && !freeTrial.card_required
|
||||
? {
|
||||
end_behavior: {
|
||||
missing_payment_method: "cancel",
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
|
||||
trial_end: freeTrialToStripeTimestamp({ freeTrial, now }),
|
||||
},
|
||||
|
||||
// ...{
|
||||
// trial_settings: rewardTrial
|
||||
// ? {
|
||||
// end_behavior: {
|
||||
// missing_payment_method: "cancel",
|
||||
// },
|
||||
// }
|
||||
// : undefined,
|
||||
|
||||
// trial_end: rewardTrialToStripeTimestamp({ rewardTrial, now }),
|
||||
// },
|
||||
});
|
||||
|
||||
const latestInvoice = subscription.latest_invoice as Stripe.Invoice;
|
||||
|
||||
@@ -1,111 +1,114 @@
|
||||
import { createFullCusProduct } from "../../../add-product/createFullCusProduct.js";
|
||||
|
||||
import {
|
||||
AttachParams,
|
||||
APIVersion,
|
||||
AttachBranch,
|
||||
type AttachConfig,
|
||||
SuccessCode,
|
||||
} from "@autumn/shared";
|
||||
import { subToPeriodStartEnd } from "@/external/stripe/stripeSubUtils/convertSubUtils.js";
|
||||
import { attachToInsertParams } from "@/internal/products/productUtils.js";
|
||||
import type { ExtendedRequest } from "@/utils/models/Request.js";
|
||||
import { createFullCusProduct } from "../../../add-product/createFullCusProduct.js";
|
||||
import {
|
||||
type AttachParams,
|
||||
AttachResultSchema,
|
||||
} from "../../../cusProducts/AttachParams.js";
|
||||
import { APIVersion, AttachBranch, AttachConfig } from "@autumn/shared";
|
||||
import { attachToInsertParams } from "@/internal/products/productUtils.js";
|
||||
import { SuccessCode } from "@autumn/shared";
|
||||
import { ExtendedRequest } from "@/utils/models/Request.js";
|
||||
import { handlePaidProduct } from "./handlePaidProduct.js";
|
||||
import { attachParamsToCurCusProduct } from "../../attachUtils/convertAttachParams.js";
|
||||
import { getDefaultAttachConfig } from "../../attachUtils/getAttachConfig.js";
|
||||
import { getMergeCusProduct } from "./getMergeCusProduct.js";
|
||||
import { subToPeriodStartEnd } from "@/external/stripe/stripeSubUtils/convertSubUtils.js";
|
||||
import { handlePaidProduct } from "./handlePaidProduct.js";
|
||||
|
||||
export const handleAddProduct = async ({
|
||||
req,
|
||||
res,
|
||||
attachParams,
|
||||
config,
|
||||
branch,
|
||||
req,
|
||||
res,
|
||||
attachParams,
|
||||
config,
|
||||
branch,
|
||||
}: {
|
||||
req: ExtendedRequest;
|
||||
res?: any;
|
||||
attachParams: AttachParams;
|
||||
config?: AttachConfig;
|
||||
branch?: AttachBranch;
|
||||
req: ExtendedRequest;
|
||||
res?: any;
|
||||
attachParams: AttachParams;
|
||||
config?: AttachConfig;
|
||||
branch?: AttachBranch;
|
||||
}) => {
|
||||
const logger = req.logtail;
|
||||
const { customer, products, prices } = attachParams;
|
||||
const logger = req.logtail;
|
||||
const { customer, products, prices } = attachParams;
|
||||
|
||||
const defaultConfig: AttachConfig = getDefaultAttachConfig();
|
||||
const defaultConfig: AttachConfig = getDefaultAttachConfig();
|
||||
|
||||
// 1. If paid product
|
||||
// 1. If paid product
|
||||
|
||||
if (prices.length > 0) {
|
||||
await handlePaidProduct({
|
||||
req,
|
||||
res,
|
||||
attachParams,
|
||||
config: config || defaultConfig,
|
||||
});
|
||||
if (prices.length > 0) {
|
||||
await handlePaidProduct({
|
||||
req,
|
||||
res,
|
||||
attachParams,
|
||||
config: config || defaultConfig,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info("Inserting free product in handleAddProduct");
|
||||
logger.info("Inserting free product in handleAddProduct");
|
||||
|
||||
const batchInsert = [];
|
||||
const batchInsert = [];
|
||||
|
||||
const { mergeCusProduct, mergeSub } = await getMergeCusProduct({
|
||||
attachParams,
|
||||
config: config || defaultConfig,
|
||||
products,
|
||||
});
|
||||
const { mergeSub } = await getMergeCusProduct({
|
||||
attachParams,
|
||||
config: config || defaultConfig,
|
||||
products,
|
||||
});
|
||||
|
||||
// console.log("Free trial:", attachParams.freeTrial);
|
||||
// throw new Error("test");
|
||||
// console.log("Free trial:", attachParams.freeTrial);
|
||||
// throw new Error("test");
|
||||
|
||||
for (const product of products) {
|
||||
let curCusProduct = attachParamsToCurCusProduct({ attachParams });
|
||||
let anchorToUnix = undefined;
|
||||
for (const product of products) {
|
||||
const curCusProduct = attachParamsToCurCusProduct({ attachParams });
|
||||
let anchorToUnix;
|
||||
|
||||
if (curCusProduct && config?.branch == AttachBranch.NewVersion) {
|
||||
anchorToUnix = curCusProduct.created_at;
|
||||
}
|
||||
if (curCusProduct && config?.branch === AttachBranch.NewVersion) {
|
||||
anchorToUnix = curCusProduct.created_at;
|
||||
}
|
||||
|
||||
if (mergeSub) {
|
||||
const { end } = subToPeriodStartEnd({ sub: mergeSub });
|
||||
anchorToUnix = end * 1000;
|
||||
}
|
||||
if (mergeSub) {
|
||||
const { end } = subToPeriodStartEnd({ sub: mergeSub });
|
||||
anchorToUnix = end * 1000;
|
||||
}
|
||||
|
||||
// Expire previous product
|
||||
// Expire previous product
|
||||
|
||||
batchInsert.push(
|
||||
createFullCusProduct({
|
||||
db: req.db,
|
||||
attachParams: attachToInsertParams(attachParams, product),
|
||||
billLaterOnly: true,
|
||||
carryExistingUsages: config?.carryUsage || false,
|
||||
anchorToUnix,
|
||||
logger,
|
||||
})
|
||||
);
|
||||
}
|
||||
await Promise.all(batchInsert);
|
||||
batchInsert.push(
|
||||
createFullCusProduct({
|
||||
db: req.db,
|
||||
attachParams: attachToInsertParams(attachParams, product),
|
||||
billLaterOnly: true,
|
||||
carryExistingUsages: config?.carryUsage || false,
|
||||
anchorToUnix,
|
||||
logger,
|
||||
}),
|
||||
);
|
||||
}
|
||||
await Promise.all(batchInsert);
|
||||
|
||||
logger.info("Successfully created full cus product");
|
||||
logger.info("Successfully created full cus product");
|
||||
|
||||
if (res) {
|
||||
let apiVersion = attachParams.org.api_version || APIVersion.v1;
|
||||
const productNames = products.map((p) => p.name).join(", ");
|
||||
const customerName = customer.name || customer.email || customer.id;
|
||||
if (apiVersion >= APIVersion.v1_1) {
|
||||
res.status(200).json(
|
||||
AttachResultSchema.parse({
|
||||
success: true,
|
||||
code: SuccessCode.FreeProductAttached,
|
||||
message: `Successfully attached ${productNames} to ${customerName}`,
|
||||
product_ids: products.map((p) => p.id),
|
||||
customer_id: customer.id || customer.internal_id,
|
||||
})
|
||||
);
|
||||
} else {
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (res) {
|
||||
const apiVersion = attachParams.org.api_version || APIVersion.v1;
|
||||
const productNames = products.map((p) => p.name).join(", ");
|
||||
const customerName = customer.name || customer.email || customer.id;
|
||||
if (apiVersion >= APIVersion.v1_1) {
|
||||
res.status(200).json(
|
||||
AttachResultSchema.parse({
|
||||
success: true,
|
||||
code: SuccessCode.FreeProductAttached,
|
||||
message: `Successfully attached ${productNames} to ${customerName}`,
|
||||
product_ids: products.map((p) => p.id),
|
||||
customer_id: customer.id || customer.internal_id,
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
import RecaseError from "@/utils/errorUtils.js";
|
||||
|
||||
import {
|
||||
APIVersion,
|
||||
type AttachConfig,
|
||||
AttachScenario,
|
||||
ErrCode,
|
||||
isTrialing,
|
||||
SuccessCode,
|
||||
} from "@autumn/shared";
|
||||
import type Stripe from "stripe";
|
||||
import { getEarliestPeriodEnd } from "@/external/stripe/stripeSubUtils/convertSubUtils.js";
|
||||
import { getStripeSubItems2 } from "@/external/stripe/stripeSubUtils/getStripeSubItems.js";
|
||||
import { createFullCusProduct } from "@/internal/customers/add-product/createFullCusProduct.js";
|
||||
import { handleCreateCheckout } from "@/internal/customers/add-product/handleCreateCheckout.js";
|
||||
import {
|
||||
AttachParams,
|
||||
type AttachParams,
|
||||
AttachResultSchema,
|
||||
} from "@/internal/customers/cusProducts/AttachParams.js";
|
||||
import {
|
||||
@@ -12,30 +21,23 @@ import {
|
||||
} from "@/internal/invoices/invoiceUtils.js";
|
||||
import { attachToInsertParams } from "@/internal/products/productUtils.js";
|
||||
import { ExtendedRequest } from "@/utils/models/Request.js";
|
||||
import {
|
||||
APIVersion,
|
||||
AttachBranch,
|
||||
AttachConfig,
|
||||
AttachScenario,
|
||||
ErrCode,
|
||||
SuccessCode,
|
||||
} from "@autumn/shared";
|
||||
import Stripe from "stripe";
|
||||
import {
|
||||
getEarliestPeriodEnd,
|
||||
subToPeriodStartEnd,
|
||||
} from "@/external/stripe/stripeSubUtils/convertSubUtils.js";
|
||||
import { createStripeSub2 } from "./createStripeSub2.js";
|
||||
import { getSmallestInterval } from "@/internal/products/prices/priceUtils/priceIntervalUtils.js";
|
||||
|
||||
import { handleCreateCheckout } from "@/internal/customers/add-product/handleCreateCheckout.js";
|
||||
import { getCustomerSub } from "../../attachUtils/convertAttachParams.js";
|
||||
import {
|
||||
getCustomerSchedule,
|
||||
getCustomerSub,
|
||||
paramsToCurSubSchedule,
|
||||
} from "../../attachUtils/convertAttachParams.js";
|
||||
import { paramsToSubItems } from "../../mergeUtils/paramsToSubItems.js";
|
||||
import { updateStripeSub2 } from "../upgradeFlow/updateStripeSub2.js";
|
||||
import { subToNewSchedule } from "../../mergeUtils/subToNewSchedule.js";
|
||||
import { isTrialing } from "@autumn/shared";
|
||||
import { getNextStartOfMonthUnix } from "@/internal/products/prices/billingIntervalUtils.js";
|
||||
import { addIntervalToAnchor } from "@/internal/products/prices/billingIntervalUtils2.js";
|
||||
import { handleUpgradeFlowSchedule } from "../upgradeFlow/handleUpgradeFlowSchedule.js";
|
||||
import { subIsCanceled } from "@/external/stripe/stripeSubUtils.js";
|
||||
import { rewardTrialToStripeTimestamp } from "@/internal/products/free-trials/freeTrialUtils.js";
|
||||
import RecaseError from "@/utils/errorUtils.js";
|
||||
|
||||
export const handlePaidProduct = async ({
|
||||
req,
|
||||
@@ -50,7 +52,7 @@ export const handlePaidProduct = async ({
|
||||
}) => {
|
||||
const logger = req.logtail;
|
||||
|
||||
let {
|
||||
const {
|
||||
org,
|
||||
customer,
|
||||
products,
|
||||
@@ -69,17 +71,19 @@ export const handlePaidProduct = async ({
|
||||
config,
|
||||
});
|
||||
|
||||
let subscriptions: Stripe.Subscription[] = [];
|
||||
const subscriptions: Stripe.Subscription[] = [];
|
||||
|
||||
const { sub: mergeSub, cusProduct: mergeCusProduct } = await getCustomerSub({
|
||||
attachParams,
|
||||
});
|
||||
|
||||
let sub: Stripe.Subscription | null = null;
|
||||
let schedule: Stripe.SubscriptionSchedule | null = null;
|
||||
let schedule: Stripe.SubscriptionSchedule | null | undefined = null;
|
||||
let invoice: Stripe.Invoice | undefined;
|
||||
let trialEndsAt = undefined;
|
||||
|
||||
// 1. If merge sub
|
||||
|
||||
if (mergeSub && !config.disableMerge) {
|
||||
if (mergeCusProduct?.free_trial) {
|
||||
trialEndsAt = isTrialing({
|
||||
@@ -89,9 +93,7 @@ export const handlePaidProduct = async ({
|
||||
? mergeCusProduct.trial_ends_at
|
||||
: undefined;
|
||||
}
|
||||
|
||||
attachParams.freeTrial = null;
|
||||
|
||||
// 1. If merged sub is canceled, also add to current schedule
|
||||
const newItemSet = await paramsToSubItems({
|
||||
req,
|
||||
@@ -100,7 +102,7 @@ export const handlePaidProduct = async ({
|
||||
config,
|
||||
});
|
||||
|
||||
const { updatedSub } = await updateStripeSub2({
|
||||
const { updatedSub, latestInvoice } = await updateStripeSub2({
|
||||
req,
|
||||
attachParams,
|
||||
curSub: mergeSub,
|
||||
@@ -111,19 +113,45 @@ export const handlePaidProduct = async ({
|
||||
|
||||
sub = updatedSub;
|
||||
|
||||
if (mergeSub.cancel_at) {
|
||||
logger.info("ADD PRODUCT FLOW,CREATING NEW SCHEDULE");
|
||||
if (latestInvoice) {
|
||||
invoice = await insertInvoiceFromAttach({
|
||||
db: req.db,
|
||||
stripeInvoice: latestInvoice,
|
||||
attachParams,
|
||||
logger,
|
||||
});
|
||||
}
|
||||
if (subIsCanceled({ sub: mergeSub })) {
|
||||
logger.info("ADD PRODUCT FLOW, CREATING NEW SCHEDULE");
|
||||
schedule = await subToNewSchedule({
|
||||
req,
|
||||
sub: mergeSub,
|
||||
attachParams,
|
||||
config,
|
||||
endOfBillingPeriod: mergeSub.cancel_at,
|
||||
endOfBillingPeriod: mergeSub.cancel_at!,
|
||||
removeCusProducts: attachParams.cusProducts.filter((cp) => cp.canceled),
|
||||
});
|
||||
} else {
|
||||
const res = await getCustomerSchedule({
|
||||
attachParams,
|
||||
subId: mergeSub.id,
|
||||
logger,
|
||||
});
|
||||
schedule = res.schedule;
|
||||
logger.info(`ADD PRODUCT FLOW, SCHEDULE ID: ${schedule?.id}`);
|
||||
if (schedule) {
|
||||
await handleUpgradeFlowSchedule({
|
||||
req,
|
||||
logger,
|
||||
attachParams,
|
||||
config,
|
||||
schedule,
|
||||
curSub: mergeSub,
|
||||
removeCusProducts: [],
|
||||
fromAddProduct: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 1.
|
||||
} else {
|
||||
let billingCycleAnchorUnix = undefined;
|
||||
const smallestInterval = getSmallestInterval({
|
||||
@@ -152,6 +180,7 @@ export const handlePaidProduct = async ({
|
||||
billingCycleAnchorUnix = attachParams.billingAnchor;
|
||||
}
|
||||
|
||||
// console.log("Item set: ", itemSet);
|
||||
try {
|
||||
sub = await createStripeSub2({
|
||||
db: req.db,
|
||||
@@ -162,6 +191,15 @@ export const handlePaidProduct = async ({
|
||||
config,
|
||||
logger,
|
||||
});
|
||||
|
||||
if (sub?.latest_invoice) {
|
||||
invoice = await insertInvoiceFromAttach({
|
||||
db: req.db,
|
||||
stripeInvoice: sub.latest_invoice as Stripe.Invoice,
|
||||
attachParams,
|
||||
logger,
|
||||
});
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (
|
||||
error instanceof RecaseError &&
|
||||
@@ -182,16 +220,6 @@ export const handlePaidProduct = async ({
|
||||
|
||||
subscriptions.push(sub);
|
||||
|
||||
let invoice: Stripe.Invoice | undefined;
|
||||
if (sub?.latest_invoice) {
|
||||
invoice = await insertInvoiceFromAttach({
|
||||
db: req.db,
|
||||
stripeInvoice: sub.latest_invoice as Stripe.Invoice,
|
||||
attachParams,
|
||||
logger,
|
||||
});
|
||||
}
|
||||
|
||||
const anchorToUnix = getEarliestPeriodEnd({ sub }) * 1000;
|
||||
|
||||
if (config.invoiceCheckout) {
|
||||
@@ -216,7 +244,14 @@ export const handlePaidProduct = async ({
|
||||
anchorToUnix,
|
||||
carryExistingUsages: config.carryUsage,
|
||||
scenario: AttachScenario.New,
|
||||
trialEndsAt: trialEndsAt || undefined,
|
||||
trialEndsAt:
|
||||
trialEndsAt ||
|
||||
(attachParams.rewardTrial
|
||||
? (rewardTrialToStripeTimestamp({
|
||||
rewardTrial: attachParams.rewardTrial,
|
||||
now: attachParams.now,
|
||||
}) || 0) * 1000
|
||||
: undefined),
|
||||
logger,
|
||||
})
|
||||
);
|
||||
@@ -224,7 +259,7 @@ export const handlePaidProduct = async ({
|
||||
await Promise.all(batchInsert);
|
||||
|
||||
if (res) {
|
||||
let apiVersion = attachParams.apiVersion || APIVersion.v1;
|
||||
const apiVersion = attachParams.apiVersion || APIVersion.v1;
|
||||
const productNames = products.map((p) => p.name).join(", ");
|
||||
const customerName = customer.name || customer.email || customer.id;
|
||||
if (apiVersion >= APIVersion.v1_1) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ export const handleUpgradeFlowSchedule = async ({
|
||||
curSub,
|
||||
removeCusProducts,
|
||||
logger,
|
||||
fromAddProduct = false,
|
||||
}: {
|
||||
req: ExtendedRequest;
|
||||
attachParams: AttachParams;
|
||||
@@ -32,17 +33,17 @@ export const handleUpgradeFlowSchedule = async ({
|
||||
curSub: Stripe.Subscription;
|
||||
removeCusProducts?: FullCusProduct[];
|
||||
logger: any;
|
||||
fromAddProduct?: boolean;
|
||||
}) => {
|
||||
logger.info(`UPGRADE FLOW, updating schedule ${schedule?.id}`);
|
||||
if (fromAddProduct) {
|
||||
logger.info(`ADD PRODUCT FLOW, updating schedule ${schedule?.id}`);
|
||||
} else {
|
||||
logger.info(`UPGRADE FLOW, updating schedule ${schedule?.id}`);
|
||||
}
|
||||
|
||||
const { stripeCli, customer, prices } = attachParams;
|
||||
const curCusProduct = attachParamsToCurCusProduct({ attachParams });
|
||||
|
||||
// console.log("CUR ITEMS:");
|
||||
// await logPhases({
|
||||
// phases: schedule.phases as any,
|
||||
// db: req.db,
|
||||
// });
|
||||
|
||||
const currentPhaseIndex = getCurrentPhaseIndex({
|
||||
schedule,
|
||||
now: attachParams.now,
|
||||
|
||||
@@ -1,155 +1,163 @@
|
||||
import Stripe from "stripe";
|
||||
import { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js";
|
||||
import { AttachConfig, ProrationBehavior } from "@autumn/shared";
|
||||
import { attachParamToCusProducts } from "../../attachUtils/convertAttachParams.js";
|
||||
import {
|
||||
createUsageInvoiceItems,
|
||||
resetUsageBalances,
|
||||
} from "../upgradeDiffIntFlow/createUsageInvoiceItems.js";
|
||||
import { ExtendedRequest } from "@/utils/models/Request.js";
|
||||
import { createProrationInvoice } from "@/external/stripe/stripeSubUtils/updateStripeSub/createProrationinvoice.js";
|
||||
import { createAndFilterContUseItems } from "../../attachUtils/getContUseItems/createContUseInvoiceItems.js";
|
||||
import { freeTrialToStripeTimestamp } from "@/internal/products/free-trials/freeTrialUtils.js";
|
||||
import { getContUseInvoiceItems } from "../../attachUtils/getContUseItems/getContUseInvoiceItems.js";
|
||||
import { ItemSet } from "@/utils/models/ItemSet.js";
|
||||
import { type AttachConfig, ProrationBehavior } from "@autumn/shared";
|
||||
import type Stripe from "stripe";
|
||||
import { sanitizeSubItems } from "@/external/stripe/stripeSubUtils/getStripeSubItems.js";
|
||||
import { createProrationInvoice } from "@/external/stripe/stripeSubUtils/updateStripeSub/createProrationinvoice.js";
|
||||
import type { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js";
|
||||
import {
|
||||
freeTrialToStripeTimestamp,
|
||||
rewardTrialToStripeTimestamp,
|
||||
} from "@/internal/products/free-trials/freeTrialUtils.js";
|
||||
import { SubService } from "@/internal/subscriptions/SubService.js";
|
||||
import { createStripeCli } from "@/external/stripe/utils.js";
|
||||
import { nullish } from "@/utils/genUtils.js";
|
||||
import RecaseError from "@/utils/errorUtils.js";
|
||||
import { nullish } from "@/utils/genUtils.js";
|
||||
import type { ItemSet } from "@/utils/models/ItemSet.js";
|
||||
import type { ExtendedRequest } from "@/utils/models/Request.js";
|
||||
import { attachParamToCusProducts } from "../../attachUtils/convertAttachParams.js";
|
||||
import { createAndFilterContUseItems } from "../../attachUtils/getContUseItems/createContUseInvoiceItems.js";
|
||||
import {
|
||||
createUsageInvoiceItems,
|
||||
resetUsageBalances,
|
||||
} from "../upgradeDiffIntFlow/createUsageInvoiceItems.js";
|
||||
|
||||
export const updateStripeSub2 = async ({
|
||||
req,
|
||||
attachParams,
|
||||
config,
|
||||
curSub,
|
||||
itemSet,
|
||||
fromCreate = false,
|
||||
req,
|
||||
attachParams,
|
||||
config,
|
||||
curSub,
|
||||
itemSet,
|
||||
fromCreate = false,
|
||||
}: {
|
||||
req: ExtendedRequest;
|
||||
attachParams: AttachParams;
|
||||
config: AttachConfig;
|
||||
curSub: Stripe.Subscription;
|
||||
itemSet: ItemSet;
|
||||
fromCreate?: boolean;
|
||||
req: ExtendedRequest;
|
||||
attachParams: AttachParams;
|
||||
config: AttachConfig;
|
||||
curSub: Stripe.Subscription;
|
||||
itemSet: ItemSet;
|
||||
fromCreate?: boolean;
|
||||
}) => {
|
||||
const { db, logger } = req;
|
||||
const { db, logger } = req;
|
||||
|
||||
const { stripeCli, customer, org, paymentMethod } = attachParams;
|
||||
const { invoiceOnly, proration } = config;
|
||||
const { stripeCli, customer, org, paymentMethod, rewardTrial } = attachParams;
|
||||
const { invoiceOnly, proration } = config;
|
||||
|
||||
if (!invoiceOnly && !attachParams.fromCancel && nullish(paymentMethod)) {
|
||||
throw new RecaseError({
|
||||
message: "Payment method is required",
|
||||
code: "payment_method_required",
|
||||
});
|
||||
}
|
||||
if (
|
||||
!invoiceOnly &&
|
||||
!attachParams.fromCancel &&
|
||||
!rewardTrial &&
|
||||
nullish(paymentMethod)
|
||||
) {
|
||||
throw new RecaseError({
|
||||
message: "Payment method is required",
|
||||
code: "payment_method_required",
|
||||
});
|
||||
}
|
||||
|
||||
if (curSub.billing_mode.type !== "flexible") {
|
||||
curSub = await stripeCli.subscriptions.migrate(curSub.id, {
|
||||
billing_mode: { type: "flexible" },
|
||||
});
|
||||
}
|
||||
if (curSub.billing_mode.type !== "flexible") {
|
||||
curSub = await stripeCli.subscriptions.migrate(curSub.id, {
|
||||
billing_mode: { type: "flexible" },
|
||||
});
|
||||
}
|
||||
|
||||
let trialEnd =
|
||||
config.disableTrial || config.carryTrial
|
||||
? undefined
|
||||
: freeTrialToStripeTimestamp({
|
||||
freeTrial: attachParams.freeTrial,
|
||||
now: attachParams.now,
|
||||
});
|
||||
const trialEnd =
|
||||
config.disableTrial || config.carryTrial
|
||||
? undefined
|
||||
: rewardTrial?.duration_value
|
||||
? rewardTrialToStripeTimestamp({ rewardTrial, now: attachParams.now })
|
||||
: freeTrialToStripeTimestamp({
|
||||
freeTrial: attachParams.freeTrial,
|
||||
now: attachParams.now,
|
||||
});
|
||||
|
||||
// 1. Update subscription
|
||||
// 1. Update subscription
|
||||
|
||||
let updatedSub = await stripeCli.subscriptions.update(curSub.id, {
|
||||
items: sanitizeSubItems(itemSet.subItems),
|
||||
proration_behavior:
|
||||
proration == ProrationBehavior.None
|
||||
? "none"
|
||||
: fromCreate
|
||||
? "always_invoice"
|
||||
: "create_prorations",
|
||||
// proration_behavior: "create_prorations",
|
||||
trial_end: trialEnd,
|
||||
// default_payment_method: paymentMethod?.id,
|
||||
add_invoice_items: itemSet.invoiceItems,
|
||||
...((invoiceOnly && {
|
||||
collection_method: "send_invoice",
|
||||
days_until_due: 30,
|
||||
}) as any),
|
||||
payment_behavior: "error_if_incomplete",
|
||||
expand: ["latest_invoice"],
|
||||
});
|
||||
const updatedSub = await stripeCli.subscriptions.update(curSub.id, {
|
||||
items: sanitizeSubItems(itemSet.subItems),
|
||||
proration_behavior:
|
||||
proration === ProrationBehavior.None
|
||||
? "none"
|
||||
: fromCreate
|
||||
? "always_invoice"
|
||||
: "create_prorations",
|
||||
// proration_behavior: "create_prorations",
|
||||
trial_end: trialEnd,
|
||||
// default_payment_method: paymentMethod?.id,
|
||||
add_invoice_items: itemSet.invoiceItems,
|
||||
...((invoiceOnly && {
|
||||
collection_method: "send_invoice",
|
||||
days_until_due: 30,
|
||||
}) as any),
|
||||
payment_behavior: "error_if_incomplete",
|
||||
expand: ["latest_invoice"],
|
||||
});
|
||||
|
||||
let latestInvoice = updatedSub.latest_invoice as Stripe.Invoice | null;
|
||||
let latestInvoice = updatedSub.latest_invoice as Stripe.Invoice | null;
|
||||
|
||||
await SubService.updateFromStripe({ db, stripeSub: updatedSub });
|
||||
await SubService.updateFromStripe({ db, stripeSub: updatedSub });
|
||||
|
||||
if (proration == ProrationBehavior.None) {
|
||||
return {
|
||||
updatedSub,
|
||||
latestInvoice: null,
|
||||
};
|
||||
}
|
||||
if (proration === ProrationBehavior.None) {
|
||||
return {
|
||||
updatedSub,
|
||||
latestInvoice: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (fromCreate) {
|
||||
return {
|
||||
updatedSub,
|
||||
latestInvoice: updatedSub.latest_invoice as Stripe.Invoice,
|
||||
};
|
||||
}
|
||||
if (fromCreate) {
|
||||
return {
|
||||
updatedSub,
|
||||
latestInvoice: updatedSub.latest_invoice as Stripe.Invoice,
|
||||
};
|
||||
}
|
||||
|
||||
const { curMainProduct } = attachParamToCusProducts({ attachParams });
|
||||
const { curMainProduct } = attachParamToCusProducts({ attachParams });
|
||||
|
||||
// 2. Create prorations for single use items
|
||||
let { invoiceItems, cusEntIds } = await createUsageInvoiceItems({
|
||||
db,
|
||||
attachParams,
|
||||
cusProduct: curMainProduct!,
|
||||
sub: curSub,
|
||||
logger,
|
||||
});
|
||||
// 2. Create prorations for single use items
|
||||
const { invoiceItems, cusEntIds } = await createUsageInvoiceItems({
|
||||
db,
|
||||
attachParams,
|
||||
cusProduct: curMainProduct!,
|
||||
sub: curSub,
|
||||
logger,
|
||||
});
|
||||
|
||||
// // // 3. Create prorations for continuous use items
|
||||
// let { replaceables, newItems } = await getContUseInvoiceItems({
|
||||
// attachParams,
|
||||
// cusProduct: curMainProduct!,
|
||||
// sub: curSub,
|
||||
// logger,
|
||||
// });
|
||||
// // // 3. Create prorations for continuous use items
|
||||
// let { replaceables, newItems } = await getContUseInvoiceItems({
|
||||
// attachParams,
|
||||
// cusProduct: curMainProduct!,
|
||||
// sub: curSub,
|
||||
// logger,
|
||||
// });
|
||||
|
||||
const { replaceables } = await createAndFilterContUseItems({
|
||||
attachParams,
|
||||
curMainProduct: curMainProduct!,
|
||||
sub: curSub,
|
||||
logger,
|
||||
});
|
||||
const { replaceables } = await createAndFilterContUseItems({
|
||||
attachParams,
|
||||
curMainProduct: curMainProduct!,
|
||||
sub: curSub,
|
||||
logger,
|
||||
});
|
||||
|
||||
if (proration === ProrationBehavior.Immediately) {
|
||||
latestInvoice = await createProrationInvoice({
|
||||
attachParams,
|
||||
invoiceOnly,
|
||||
curSub,
|
||||
updatedSub,
|
||||
logger,
|
||||
});
|
||||
if (proration === ProrationBehavior.Immediately) {
|
||||
latestInvoice = await createProrationInvoice({
|
||||
attachParams,
|
||||
invoiceOnly,
|
||||
curSub,
|
||||
updatedSub,
|
||||
logger,
|
||||
});
|
||||
|
||||
console.log(`FINALIZED INVOICE ${latestInvoice?.id}`);
|
||||
console.log(latestInvoice?.lines.data.map((line) => line.description));
|
||||
}
|
||||
console.log(`FINALIZED INVOICE ${latestInvoice?.id}`);
|
||||
console.log(latestInvoice?.lines.data.map((line) => line.description));
|
||||
}
|
||||
|
||||
await resetUsageBalances({
|
||||
db,
|
||||
cusEntIds,
|
||||
cusProduct: curMainProduct!,
|
||||
});
|
||||
await resetUsageBalances({
|
||||
db,
|
||||
cusEntIds,
|
||||
cusProduct: curMainProduct!,
|
||||
});
|
||||
|
||||
return {
|
||||
updatedSub,
|
||||
latestInvoice: latestInvoice,
|
||||
cusEntIds,
|
||||
replaceables,
|
||||
};
|
||||
return {
|
||||
updatedSub,
|
||||
latestInvoice: latestInvoice,
|
||||
cusEntIds,
|
||||
replaceables,
|
||||
};
|
||||
};
|
||||
|
||||
// await SubService.addUsageFeatures({
|
||||
|
||||
@@ -1,23 +1,29 @@
|
||||
import {
|
||||
AttachParams,
|
||||
InsertCusProductParams,
|
||||
} from "@/internal/customers/cusProducts/AttachParams.js";
|
||||
import {
|
||||
type Customer,
|
||||
cusProductToEnts,
|
||||
cusProductToPrices,
|
||||
cusProductToProduct,
|
||||
type Entity,
|
||||
type FreeTrial,
|
||||
type FullCusProduct,
|
||||
type FullCustomer,
|
||||
type FullProduct,
|
||||
type FullRewardProgram,
|
||||
type Organization,
|
||||
} from "@autumn/shared";
|
||||
import type Stripe from "stripe";
|
||||
import { createStripeCli } from "@/external/stripe/utils.js";
|
||||
import type {
|
||||
AttachParams,
|
||||
InsertCusProductParams,
|
||||
} from "@/internal/customers/cusProducts/AttachParams.js";
|
||||
import { newCusToFullCus } from "@/internal/customers/cusUtils/cusUtils.js";
|
||||
import { ExtendedRequest } from "@/utils/models/Request.js";
|
||||
import {
|
||||
Customer,
|
||||
Entity,
|
||||
FullCusProduct,
|
||||
FullCustomer,
|
||||
FullProduct,
|
||||
FreeTrial,
|
||||
} from "@autumn/shared";
|
||||
import Stripe from "stripe";
|
||||
isFreeProduct,
|
||||
isOneOff,
|
||||
itemsAreOneOff,
|
||||
} from "@/internal/products/productUtils.js";
|
||||
import type { ExtendedRequest } from "@/utils/models/Request.js";
|
||||
|
||||
export const webhookToAttachParams = ({
|
||||
req,
|
||||
@@ -156,3 +162,44 @@ export const newCusToInsertParams = ({
|
||||
features: [],
|
||||
} satisfies InsertCusProductParams;
|
||||
};
|
||||
|
||||
export const rewardProgramToAttachParams = ({
|
||||
req,
|
||||
rewardProgram,
|
||||
customer,
|
||||
product,
|
||||
org,
|
||||
}: {
|
||||
req: ExtendedRequest;
|
||||
rewardProgram: FullRewardProgram;
|
||||
customer: FullCustomer;
|
||||
product: FullProduct;
|
||||
org?: Organization;
|
||||
}): AttachParams => {
|
||||
const reward = rewardProgram.reward;
|
||||
const isPaid = !isFreeProduct(product.prices);
|
||||
const isRecurring =
|
||||
!isOneOff(product.prices) && !itemsAreOneOff(product.entitlements);
|
||||
|
||||
return {
|
||||
req,
|
||||
org: org || req.org,
|
||||
customer,
|
||||
products: [product],
|
||||
prices: product.prices,
|
||||
entitlements: product.entitlements,
|
||||
freeTrial: null,
|
||||
// rewardTrial:
|
||||
// (isPaid && isRecurring && reward.free_product_config)
|
||||
// ? reward.free_product_config
|
||||
// : null,
|
||||
rewards: [reward],
|
||||
optionsList: [],
|
||||
cusProducts: customer.customer_products,
|
||||
entities: [],
|
||||
features: req.features,
|
||||
stripeCli: createStripeCli({ org: org || req.org, env: req.env }),
|
||||
paymentMethod: null,
|
||||
replaceables: [],
|
||||
} satisfies AttachParams;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -99,9 +99,7 @@ const getOptionsToUpdate = ({
|
||||
internalFeatureId: internalFeatureId!,
|
||||
});
|
||||
|
||||
if (price?.config.interval == BillingInterval.OneOff) {
|
||||
continue;
|
||||
}
|
||||
if (price?.config.interval == BillingInterval.OneOff) continue;
|
||||
|
||||
if (existingOptions && existingOptions.quantity !== newOptions.quantity) {
|
||||
optionsToUpdate.push({
|
||||
@@ -117,9 +115,13 @@ const getOptionsToUpdate = ({
|
||||
export const checkSameCustom = async ({
|
||||
attachParams,
|
||||
curSameProduct,
|
||||
fromPreview,
|
||||
optionsToUpdate,
|
||||
}: {
|
||||
attachParams: AttachParams;
|
||||
curSameProduct: FullCusProduct;
|
||||
fromPreview?: boolean;
|
||||
optionsToUpdate: { new: FeatureOptions; old: FeatureOptions }[];
|
||||
}) => {
|
||||
let product = attachParams.products[0];
|
||||
|
||||
@@ -136,6 +138,19 @@ export const checkSameCustom = async ({
|
||||
});
|
||||
|
||||
if (itemsSame && freeTrialsSame) {
|
||||
if (
|
||||
fromPreview &&
|
||||
hasPrepaidPrice({ prices: attachParams.prices, excludeOneOff: true })
|
||||
) {
|
||||
return AttachBranch.UpdatePrepaidQuantity;
|
||||
}
|
||||
|
||||
// 1. If prepaid quantity changed
|
||||
if (optionsToUpdate.length > 0) {
|
||||
attachParams.optionsToUpdate = optionsToUpdate;
|
||||
return AttachBranch.UpdatePrepaidQuantity;
|
||||
}
|
||||
|
||||
throw new RecaseError({
|
||||
message: `Items specified for ${product.name} are the same as the existing product, can't attach again`,
|
||||
code: ErrCode.InvalidRequest,
|
||||
@@ -175,29 +190,25 @@ const getSameProductBranch = async ({
|
||||
return AttachBranch.NewVersion;
|
||||
}
|
||||
|
||||
// 2. Same custom?
|
||||
|
||||
if (attachParams.isCustom && curScheduledProduct?.product.id !== product.id) {
|
||||
return await checkSameCustom({ attachParams, curSameProduct });
|
||||
}
|
||||
|
||||
let optionsToUpdate = getOptionsToUpdate({
|
||||
oldOptionsList: curSameProduct.options,
|
||||
newOptionsList: attachParams.optionsList,
|
||||
curSameProduct,
|
||||
});
|
||||
|
||||
// 2. Same custom?
|
||||
if (attachParams.isCustom && curScheduledProduct?.product.id !== product.id) {
|
||||
return await checkSameCustom({
|
||||
attachParams,
|
||||
curSameProduct,
|
||||
fromPreview,
|
||||
optionsToUpdate,
|
||||
});
|
||||
}
|
||||
|
||||
// 1. If prepaid quantity changed
|
||||
if (optionsToUpdate.length > 0) {
|
||||
attachParams.optionsToUpdate = optionsToUpdate;
|
||||
if (attachParams.isCustom) {
|
||||
throw new RecaseError({
|
||||
message: `Not allowed to update prepaid quantity for current product if is_custom is true`,
|
||||
code: ErrCode.InternalError,
|
||||
statusCode: 500,
|
||||
});
|
||||
}
|
||||
|
||||
return AttachBranch.UpdatePrepaidQuantity;
|
||||
}
|
||||
|
||||
|
||||
@@ -124,9 +124,21 @@ export const getAttachConfig = async ({
|
||||
branch != AttachBranch.MultiAttachUpdate);
|
||||
|
||||
const onlyCheckout = !isFree && checkoutFlow && !freeTrialWithoutCardRequired;
|
||||
|
||||
const disableMerge = branch == AttachBranch.MainIsTrial || onlyCheckout;
|
||||
|
||||
// Require payment method...
|
||||
let paymentMethodRequired = true;
|
||||
if (
|
||||
!disableTrial &&
|
||||
attachParams.freeTrial &&
|
||||
attachParams.freeTrial.card_required === false
|
||||
) {
|
||||
paymentMethodRequired = false;
|
||||
}
|
||||
if (attachParams.invoiceOnly) {
|
||||
paymentMethodRequired = false;
|
||||
}
|
||||
|
||||
let config: AttachConfig = {
|
||||
branch,
|
||||
onlyCheckout,
|
||||
@@ -141,6 +153,7 @@ export const getAttachConfig = async ({
|
||||
finalizeInvoice: notNullish(attachBody.finalize_invoice)
|
||||
? attachBody.finalize_invoice!
|
||||
: true,
|
||||
requirePaymentMethod: paymentMethodRequired,
|
||||
};
|
||||
|
||||
return { flags, config };
|
||||
@@ -159,6 +172,7 @@ export const getDefaultAttachConfig = () => {
|
||||
carryTrial: false,
|
||||
invoiceCheckout: false,
|
||||
finalizeInvoice: true,
|
||||
requirePaymentMethod: true,
|
||||
};
|
||||
|
||||
return config;
|
||||
|
||||
@@ -1,23 +1,26 @@
|
||||
import {
|
||||
type AttachBody,
|
||||
AttachBranch,
|
||||
type AttachConfig,
|
||||
AttachFunction,
|
||||
CusProductStatus,
|
||||
} from "@autumn/shared";
|
||||
import chalk from "chalk";
|
||||
import { AttachParams } from "../../cusProducts/AttachParams.js";
|
||||
import { AttachBranch, AttachFunction, CusProductStatus } from "@autumn/shared";
|
||||
import { handleCreateCheckout } from "../../add-product/handleCreateCheckout.js";
|
||||
import { handleCreateInvoiceCheckout } from "../../add-product/handleCreateInvoiceCheckout.js";
|
||||
import type { AttachParams } from "../../cusProducts/AttachParams.js";
|
||||
import { CusProductService } from "../../cusProducts/CusProductService.js";
|
||||
import { handleAddProduct } from "../attachFunctions/addProductFlow/handleAddProduct.js";
|
||||
import { AttachBody } from "@autumn/shared";
|
||||
import { AttachConfig } from "@autumn/shared";
|
||||
import { handleOneOffFunction } from "../attachFunctions/addProductFlow/handleOneOffFunction.js";
|
||||
import { handleRenewProduct } from "../attachFunctions/handleRenewProduct.js";
|
||||
import { handleMultiAttachFlow } from "../attachFunctions/multiAttach/handleMultiAttachFlow.js";
|
||||
import { handleScheduleFunction2 } from "../attachFunctions/scheduleFlow/handleScheduleFlow2.js";
|
||||
import { handleUpdateQuantityFunction } from "../attachFunctions/updateQuantityFlow/updateQuantityFlow.js";
|
||||
|
||||
import { handleUpgradeFlow } from "../attachFunctions/upgradeFlow/handleUpgradeFlow.js";
|
||||
import {
|
||||
attachParamsToCurCusProduct,
|
||||
attachParamToCusProducts,
|
||||
} from "./convertAttachParams.js";
|
||||
import { handleOneOffFunction } from "../attachFunctions/addProductFlow/handleOneOffFunction.js";
|
||||
import { CusProductService } from "../../cusProducts/CusProductService.js";
|
||||
import { handleCreateInvoiceCheckout } from "../../add-product/handleCreateInvoiceCheckout.js";
|
||||
import { handleUpgradeFlow } from "../attachFunctions/upgradeFlow/handleUpgradeFlow.js";
|
||||
import { handleScheduleFunction2 } from "../attachFunctions/scheduleFlow/handleScheduleFlow2.js";
|
||||
import { handleRenewProduct } from "../attachFunctions/handleRenewProduct.js";
|
||||
import { handleMultiAttachFlow } from "../attachFunctions/multiAttach/handleMultiAttachFlow.js";
|
||||
|
||||
/*
|
||||
1. If from new version, free trial should just carry over
|
||||
@@ -27,252 +30,252 @@ import { handleMultiAttachFlow } from "../attachFunctions/multiAttach/handleMult
|
||||
*/
|
||||
|
||||
export const getAttachFunction = async ({
|
||||
branch,
|
||||
attachParams,
|
||||
attachBody,
|
||||
config,
|
||||
branch,
|
||||
attachParams,
|
||||
attachBody,
|
||||
config,
|
||||
}: {
|
||||
branch: AttachBranch;
|
||||
attachParams: AttachParams;
|
||||
attachBody: AttachBody;
|
||||
config: AttachConfig;
|
||||
branch: AttachBranch;
|
||||
attachParams: AttachParams;
|
||||
attachBody: AttachBody;
|
||||
config: AttachConfig;
|
||||
}) => {
|
||||
const { onlyCheckout } = config;
|
||||
const { curCusProduct } = attachParamToCusProducts({
|
||||
attachParams,
|
||||
});
|
||||
const { onlyCheckout } = config;
|
||||
const { curCusProduct } = attachParamToCusProducts({
|
||||
attachParams,
|
||||
});
|
||||
|
||||
// 1. Checkout function
|
||||
const newScenario = [
|
||||
AttachBranch.MultiAttach,
|
||||
AttachBranch.MultiProduct,
|
||||
AttachBranch.OneOff,
|
||||
AttachBranch.New,
|
||||
AttachBranch.AddOn,
|
||||
AttachBranch.MainIsFree,
|
||||
AttachBranch.MainIsTrial,
|
||||
].includes(branch);
|
||||
// 1. Checkout function
|
||||
const newScenario = [
|
||||
AttachBranch.MultiAttach,
|
||||
AttachBranch.MultiProduct,
|
||||
AttachBranch.OneOff,
|
||||
AttachBranch.New,
|
||||
AttachBranch.AddOn,
|
||||
AttachBranch.MainIsFree,
|
||||
AttachBranch.MainIsTrial,
|
||||
].includes(branch);
|
||||
|
||||
if (newScenario && onlyCheckout) {
|
||||
return AttachFunction.CreateCheckout;
|
||||
} else if (branch == AttachBranch.OneOff) {
|
||||
return AttachFunction.OneOff;
|
||||
} else if (
|
||||
branch == AttachBranch.MultiAttach ||
|
||||
branch == AttachBranch.MultiAttachUpdate
|
||||
) {
|
||||
return AttachFunction.MultiAttach;
|
||||
} else if (newScenario) {
|
||||
return AttachFunction.AddProduct;
|
||||
}
|
||||
if (newScenario && onlyCheckout) {
|
||||
return AttachFunction.CreateCheckout;
|
||||
} else if (branch == AttachBranch.OneOff) {
|
||||
return AttachFunction.OneOff;
|
||||
} else if (
|
||||
branch == AttachBranch.MultiAttach ||
|
||||
branch == AttachBranch.MultiAttachUpdate
|
||||
) {
|
||||
return AttachFunction.MultiAttach;
|
||||
} else if (newScenario) {
|
||||
return AttachFunction.AddProduct;
|
||||
}
|
||||
|
||||
// 2. Upgrade scenarios
|
||||
let updateScenarios = [
|
||||
AttachBranch.NewVersion,
|
||||
AttachBranch.SameCustom,
|
||||
AttachBranch.SameCustomEnts,
|
||||
AttachBranch.Upgrade,
|
||||
];
|
||||
// 2. Upgrade scenarios
|
||||
const updateScenarios = [
|
||||
AttachBranch.NewVersion,
|
||||
AttachBranch.SameCustom,
|
||||
AttachBranch.SameCustomEnts,
|
||||
AttachBranch.Upgrade,
|
||||
];
|
||||
|
||||
if (updateScenarios.includes(branch)) {
|
||||
if (config.sameIntervals) {
|
||||
return AttachFunction.UpgradeSameInterval;
|
||||
} else {
|
||||
return AttachFunction.UpgradeDiffInterval;
|
||||
}
|
||||
}
|
||||
if (updateScenarios.includes(branch)) {
|
||||
if (config.sameIntervals) {
|
||||
return AttachFunction.UpgradeSameInterval;
|
||||
} else {
|
||||
return AttachFunction.UpgradeDiffInterval;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Downgrade scenarios
|
||||
if (branch == AttachBranch.Downgrade) {
|
||||
return AttachFunction.ScheduleProduct;
|
||||
}
|
||||
// 3. Downgrade scenarios
|
||||
if (branch == AttachBranch.Downgrade) {
|
||||
return AttachFunction.ScheduleProduct;
|
||||
}
|
||||
|
||||
// 4. Prepaid scenarios
|
||||
if (branch == AttachBranch.UpdatePrepaidQuantity) {
|
||||
let curSameProduct = attachParamsToCurCusProduct({ attachParams });
|
||||
if (curSameProduct?.free_trial) {
|
||||
attachParams.freeTrial = curSameProduct.free_trial;
|
||||
}
|
||||
return AttachFunction.UpdatePrepaidQuantity;
|
||||
}
|
||||
// 4. Prepaid scenarios
|
||||
if (branch == AttachBranch.UpdatePrepaidQuantity) {
|
||||
const curSameProduct = attachParamsToCurCusProduct({ attachParams });
|
||||
if (curSameProduct?.free_trial) {
|
||||
attachParams.freeTrial = curSameProduct.free_trial;
|
||||
}
|
||||
return AttachFunction.UpdatePrepaidQuantity;
|
||||
}
|
||||
|
||||
if (branch == AttachBranch.Renew) {
|
||||
return AttachFunction.Renew;
|
||||
}
|
||||
if (branch == AttachBranch.Renew) {
|
||||
return AttachFunction.Renew;
|
||||
}
|
||||
|
||||
return AttachFunction.AddProduct;
|
||||
return AttachFunction.AddProduct;
|
||||
};
|
||||
|
||||
export const runAttachFunction = async ({
|
||||
req,
|
||||
res,
|
||||
branch,
|
||||
attachParams,
|
||||
attachBody,
|
||||
config,
|
||||
req,
|
||||
res,
|
||||
branch,
|
||||
attachParams,
|
||||
attachBody,
|
||||
config,
|
||||
}: {
|
||||
req: any;
|
||||
res: any;
|
||||
branch: AttachBranch;
|
||||
attachParams: AttachParams;
|
||||
attachBody: AttachBody;
|
||||
config: AttachConfig;
|
||||
req: any;
|
||||
res: any;
|
||||
branch: AttachBranch;
|
||||
attachParams: AttachParams;
|
||||
attachBody: AttachBody;
|
||||
config: AttachConfig;
|
||||
}) => {
|
||||
const { logtail: logger, db } = req;
|
||||
const { stripeCli } = attachParams;
|
||||
const { logtail: logger, db } = req;
|
||||
const { stripeCli } = attachParams;
|
||||
|
||||
const attachFunction = await getAttachFunction({
|
||||
branch,
|
||||
attachParams,
|
||||
attachBody,
|
||||
config,
|
||||
});
|
||||
const attachFunction = await getAttachFunction({
|
||||
branch,
|
||||
attachParams,
|
||||
attachBody,
|
||||
config,
|
||||
});
|
||||
|
||||
// console.log("Attach Function:", attachFunction);
|
||||
// throw new Error("Attach Function:");
|
||||
// console.log("Attach Function:", attachFunction);
|
||||
// throw new Error("Attach Function:");
|
||||
|
||||
const customer = attachParams.customer;
|
||||
const org = attachParams.org;
|
||||
const customer = attachParams.customer;
|
||||
const org = attachParams.org;
|
||||
|
||||
const productIdsStr = attachParams.products.map((p) => p.id).join(", ");
|
||||
const { curMainProduct, curSameProduct, curScheduledProduct } =
|
||||
attachParamToCusProducts({
|
||||
attachParams,
|
||||
});
|
||||
const productIdsStr = attachParams.products.map((p) => p.id).join(", ");
|
||||
const { curMainProduct, curSameProduct, curScheduledProduct } =
|
||||
attachParamToCusProducts({
|
||||
attachParams,
|
||||
});
|
||||
|
||||
const curCusProduct = attachParamsToCurCusProduct({ attachParams });
|
||||
const curCusProduct = attachParamsToCurCusProduct({ attachParams });
|
||||
|
||||
logger.info(`--------------------------------`);
|
||||
logger.info(
|
||||
`ATTACHING ${productIdsStr} to ${customer.name} (${customer.id || customer.email}), org: ${org.slug}\n`
|
||||
);
|
||||
if (customer.entity) {
|
||||
logger.info(`Entity: ${customer.entity.name} (${customer.entity.id})`);
|
||||
}
|
||||
logger.info(
|
||||
`Branch: ${chalk.yellow(branch)}, Function: ${chalk.yellow(attachFunction)}`
|
||||
);
|
||||
logger.info(`--------------------------------`);
|
||||
logger.info(
|
||||
`ATTACHING ${productIdsStr} to ${customer.name} (${customer.id || customer.email}), org: ${org.slug}\n`,
|
||||
);
|
||||
if (customer.entity) {
|
||||
logger.info(`Entity: ${customer.entity.name} (${customer.entity.id})`);
|
||||
}
|
||||
logger.info(
|
||||
`Branch: ${chalk.yellow(branch)}, Function: ${chalk.yellow(attachFunction)}`,
|
||||
);
|
||||
|
||||
if (curMainProduct) {
|
||||
logger.info(`→ Current Main Product: ${curMainProduct.product.id}`);
|
||||
}
|
||||
if (curSameProduct) {
|
||||
logger.info(`→ Current Same Product: ${curSameProduct.product.id}`);
|
||||
}
|
||||
if (curScheduledProduct) {
|
||||
logger.info(
|
||||
`→ Current Scheduled Product: ${curScheduledProduct.product.id}`
|
||||
);
|
||||
}
|
||||
if (curMainProduct) {
|
||||
logger.info(`→ Current Main Product: ${curMainProduct.product.id}`);
|
||||
}
|
||||
if (curSameProduct) {
|
||||
logger.info(`→ Current Same Product: ${curSameProduct.product.id}`);
|
||||
}
|
||||
if (curScheduledProduct) {
|
||||
logger.info(
|
||||
`→ Current Scheduled Product: ${curScheduledProduct.product.id}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (attachFunction == AttachFunction.OneOff) {
|
||||
return await handleOneOffFunction({
|
||||
req,
|
||||
res,
|
||||
attachParams,
|
||||
config,
|
||||
});
|
||||
}
|
||||
if (attachFunction === AttachFunction.OneOff) {
|
||||
return await handleOneOffFunction({
|
||||
req,
|
||||
res,
|
||||
attachParams,
|
||||
config,
|
||||
});
|
||||
}
|
||||
|
||||
if (attachFunction == AttachFunction.Renew) {
|
||||
return await handleRenewProduct({
|
||||
req,
|
||||
res,
|
||||
attachParams,
|
||||
config,
|
||||
});
|
||||
}
|
||||
if (attachFunction === AttachFunction.Renew) {
|
||||
return await handleRenewProduct({
|
||||
req,
|
||||
res,
|
||||
attachParams,
|
||||
config,
|
||||
});
|
||||
}
|
||||
|
||||
// 2. If main is trial, cancel it...
|
||||
if (branch == AttachBranch.MainIsTrial) {
|
||||
await CusProductService.update({
|
||||
db,
|
||||
cusProductId: curMainProduct!.id,
|
||||
updates: {
|
||||
ended_at: attachParams.now,
|
||||
canceled: true,
|
||||
status: CusProductStatus.Expired,
|
||||
},
|
||||
});
|
||||
// 2. If main is trial, cancel it...
|
||||
if (branch === AttachBranch.MainIsTrial) {
|
||||
await CusProductService.update({
|
||||
db,
|
||||
cusProductId: curMainProduct!.id,
|
||||
updates: {
|
||||
ended_at: attachParams.now,
|
||||
canceled: true,
|
||||
status: CusProductStatus.Expired,
|
||||
},
|
||||
});
|
||||
|
||||
const subId = curMainProduct?.subscription_ids?.[0];
|
||||
if (subId) {
|
||||
await stripeCli.subscriptions.cancel(subId, {
|
||||
cancellation_details: {
|
||||
comment: "autumn_downgrade,trial_canceled",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
const subId = curMainProduct?.subscription_ids?.[0];
|
||||
if (subId) {
|
||||
await stripeCli.subscriptions.cancel(subId, {
|
||||
cancellation_details: {
|
||||
comment: "autumn_downgrade,trial_canceled",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (attachFunction == AttachFunction.MultiAttach) {
|
||||
return await handleMultiAttachFlow({
|
||||
req,
|
||||
res,
|
||||
attachParams,
|
||||
attachBody,
|
||||
branch,
|
||||
config,
|
||||
});
|
||||
}
|
||||
if (attachFunction === AttachFunction.MultiAttach) {
|
||||
return await handleMultiAttachFlow({
|
||||
req,
|
||||
res,
|
||||
attachParams,
|
||||
attachBody,
|
||||
branch,
|
||||
config,
|
||||
});
|
||||
}
|
||||
|
||||
if (attachFunction == AttachFunction.CreateCheckout) {
|
||||
if (config.invoiceCheckout) {
|
||||
return await handleCreateInvoiceCheckout({
|
||||
req,
|
||||
res,
|
||||
attachParams,
|
||||
attachBody,
|
||||
config,
|
||||
branch,
|
||||
});
|
||||
}
|
||||
return await handleCreateCheckout({
|
||||
req,
|
||||
res,
|
||||
attachParams,
|
||||
config,
|
||||
});
|
||||
}
|
||||
if (attachFunction === AttachFunction.CreateCheckout) {
|
||||
if (config.invoiceCheckout) {
|
||||
return await handleCreateInvoiceCheckout({
|
||||
req,
|
||||
res,
|
||||
attachParams,
|
||||
attachBody,
|
||||
config,
|
||||
branch,
|
||||
});
|
||||
}
|
||||
return await handleCreateCheckout({
|
||||
req,
|
||||
res,
|
||||
attachParams,
|
||||
config,
|
||||
});
|
||||
}
|
||||
|
||||
if (attachFunction == AttachFunction.AddProduct) {
|
||||
return await handleAddProduct({
|
||||
req,
|
||||
res,
|
||||
attachParams,
|
||||
config,
|
||||
branch,
|
||||
});
|
||||
}
|
||||
if (attachFunction === AttachFunction.AddProduct) {
|
||||
return await handleAddProduct({
|
||||
req,
|
||||
res,
|
||||
attachParams,
|
||||
config,
|
||||
branch,
|
||||
});
|
||||
}
|
||||
|
||||
if (attachFunction == AttachFunction.ScheduleProduct) {
|
||||
return await handleScheduleFunction2({
|
||||
req,
|
||||
res,
|
||||
attachParams,
|
||||
config,
|
||||
});
|
||||
}
|
||||
if (attachFunction === AttachFunction.ScheduleProduct) {
|
||||
return await handleScheduleFunction2({
|
||||
req,
|
||||
res,
|
||||
attachParams,
|
||||
config,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
attachFunction == AttachFunction.UpgradeDiffInterval ||
|
||||
attachFunction == AttachFunction.UpgradeSameInterval
|
||||
) {
|
||||
return await handleUpgradeFlow({
|
||||
req,
|
||||
res,
|
||||
attachParams,
|
||||
config,
|
||||
branch,
|
||||
});
|
||||
}
|
||||
if (
|
||||
attachFunction === AttachFunction.UpgradeDiffInterval ||
|
||||
attachFunction === AttachFunction.UpgradeSameInterval
|
||||
) {
|
||||
return await handleUpgradeFlow({
|
||||
req,
|
||||
res,
|
||||
attachParams,
|
||||
config,
|
||||
branch,
|
||||
});
|
||||
}
|
||||
|
||||
if (attachFunction == AttachFunction.UpdatePrepaidQuantity) {
|
||||
return await handleUpdateQuantityFunction({
|
||||
req,
|
||||
res,
|
||||
attachParams,
|
||||
config,
|
||||
});
|
||||
}
|
||||
if (attachFunction === AttachFunction.UpdatePrepaidQuantity) {
|
||||
return await handleUpdateQuantityFunction({
|
||||
req,
|
||||
res,
|
||||
attachParams,
|
||||
config,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
21
server/src/internal/customers/cusCache/subCacheUtils.ts
Normal file
21
server/src/internal/customers/cusCache/subCacheUtils.ts
Normal 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;
|
||||
};
|
||||
@@ -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[];
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -82,7 +82,6 @@ export const getCustomerDetails = async ({
|
||||
subs,
|
||||
org,
|
||||
apiVersion,
|
||||
entities: customer.entities,
|
||||
features,
|
||||
});
|
||||
|
||||
|
||||
@@ -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``;
|
||||
|
||||
|
||||
@@ -7,6 +7,40 @@ import RecaseError, { handleRequestError } from "@/utils/errorUtils.js";
|
||||
import { routeHandler } from "@/utils/routerUtils.js";
|
||||
import { ErrCode } from "@autumn/shared";
|
||||
import { StatusCodes } from "http-status-codes";
|
||||
import Stripe from "stripe";
|
||||
|
||||
const createDefaultBillingPortalConfiguration = async (stripeCli: Stripe) => {
|
||||
try {
|
||||
const configuration = await stripeCli.billingPortal.configurations.create({
|
||||
features: {
|
||||
customer_update: {
|
||||
allowed_updates: ["email", "address"],
|
||||
enabled: true,
|
||||
},
|
||||
invoice_history: {
|
||||
enabled: true,
|
||||
},
|
||||
payment_method_update: {
|
||||
enabled: true,
|
||||
},
|
||||
subscription_cancel: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
// business_profile: {
|
||||
// privacy_policy_url: "https://example.com/privacy",
|
||||
// terms_of_service_url: "https://example.com/terms",
|
||||
// },
|
||||
});
|
||||
return configuration;
|
||||
} catch (error: any) {
|
||||
throw new RecaseError({
|
||||
message: `Failed to create billing portal configuration: ${error.message}`,
|
||||
code: ErrCode.StripeError,
|
||||
statusCode: StatusCodes.INTERNAL_SERVER_ERROR,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const handleCreateBillingPortal = async (req: any, res: any) =>
|
||||
routeHandler({
|
||||
@@ -37,23 +71,19 @@ export const handleCreateBillingPortal = async (req: any, res: any) =>
|
||||
|
||||
const stripeCli = createStripeCli({ org, env: req.env });
|
||||
|
||||
// Determine the Stripe customer ID to use
|
||||
let stripeCustomerId: string;
|
||||
|
||||
if (!customer.processor?.id) {
|
||||
let newCus;
|
||||
try {
|
||||
newCus = await createStripeCusIfNotExists({
|
||||
const newCus = await createStripeCusIfNotExists({
|
||||
db: req.db,
|
||||
org,
|
||||
env: req.env,
|
||||
customer,
|
||||
logger: req.logtail,
|
||||
});
|
||||
} catch (error: any) {
|
||||
throw new RecaseError({
|
||||
message: `Failed to create Stripe customer`,
|
||||
code: ErrCode.StripeError,
|
||||
statusCode: StatusCodes.INTERNAL_SERVER_ERROR,
|
||||
});
|
||||
} finally {
|
||||
|
||||
if (!newCus) {
|
||||
throw new RecaseError({
|
||||
message: `Failed to create Stripe customer`,
|
||||
@@ -62,26 +92,78 @@ export const handleCreateBillingPortal = async (req: any, res: any) =>
|
||||
});
|
||||
}
|
||||
|
||||
const portal = await stripeCli.billingPortal.sessions.create({
|
||||
customer: newCus.id,
|
||||
return_url: returnUrl || toSuccessUrl({ org, env: req.env }),
|
||||
});
|
||||
|
||||
res.status(200).json({
|
||||
customer_id: customer.id,
|
||||
url: portal.url,
|
||||
stripeCustomerId = newCus.id;
|
||||
} catch (error: any) {
|
||||
throw new RecaseError({
|
||||
message: `Failed to create Stripe customer`,
|
||||
code: ErrCode.StripeError,
|
||||
statusCode: StatusCodes.INTERNAL_SERVER_ERROR,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const portal = await stripeCli.billingPortal.sessions.create({
|
||||
customer: customer.processor.id,
|
||||
stripeCustomerId = customer.processor.id;
|
||||
}
|
||||
|
||||
// Create billing portal session
|
||||
let portal;
|
||||
try {
|
||||
portal = await stripeCli.billingPortal.sessions.create({
|
||||
customer: stripeCustomerId,
|
||||
return_url: returnUrl || toSuccessUrl({ org, env: req.env }),
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.log(`Code: ${error.code}, Message: ${error.message}`);
|
||||
|
||||
res.status(200).json({
|
||||
customer_id: customer.id,
|
||||
url: portal.url,
|
||||
});
|
||||
// Check if the error is due to missing default configuration
|
||||
if (
|
||||
error.message &&
|
||||
error.message.includes("default configuration has not been created")
|
||||
) {
|
||||
try {
|
||||
// Create a default billing portal configuration
|
||||
req.logtail?.info(
|
||||
`Creating default billing portal configuration for customer ${customer.id}`
|
||||
);
|
||||
|
||||
const configuration =
|
||||
await createDefaultBillingPortalConfiguration(stripeCli);
|
||||
|
||||
req.logtail?.info(
|
||||
"Successfully created billing portal configuration",
|
||||
{
|
||||
configurationId: configuration.id,
|
||||
orgId: org.id,
|
||||
}
|
||||
);
|
||||
|
||||
// Retry creating the portal session with the new configuration
|
||||
portal = await stripeCli.billingPortal.sessions.create({
|
||||
customer: stripeCustomerId,
|
||||
return_url: returnUrl || toSuccessUrl({ org, env: req.env }),
|
||||
configuration: configuration.id,
|
||||
});
|
||||
} catch (configError: any) {
|
||||
req.logtail?.error(
|
||||
"Failed to create billing portal configuration",
|
||||
{
|
||||
error: configError.message,
|
||||
orgId: org.id,
|
||||
}
|
||||
);
|
||||
throw new RecaseError({
|
||||
message: `Failed to create billing portal configuration: ${configError.message}`,
|
||||
code: ErrCode.StripeError,
|
||||
statusCode: StatusCodes.INTERNAL_SERVER_ERROR,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
res.status(200).json({
|
||||
customer_id: customer.id,
|
||||
url: portal.url,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -78,7 +78,6 @@ const handleIdIsNull = async ({
|
||||
}
|
||||
|
||||
// 2. Check if email already exists
|
||||
|
||||
let existingCustomers = await CusService.getByEmail({
|
||||
db,
|
||||
email: newCus.email,
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
import { CusService } from "@/internal/customers/CusService.js";
|
||||
import { routeHandler } from "@/utils/routerUtils.js";
|
||||
import {
|
||||
APIVersion,
|
||||
CusExpand,
|
||||
CusProductStatus,
|
||||
ErrCode,
|
||||
} from "@autumn/shared";
|
||||
import { APIVersion, CusExpand, ErrCode } from "@autumn/shared";
|
||||
import { StatusCodes } from "http-status-codes";
|
||||
import { getCustomerDetails } from "../cusUtils/getCustomerDetails.js";
|
||||
import { parseCusExpand } from "../cusUtils/cusUtils.js";
|
||||
@@ -30,9 +24,7 @@ export const handleGetCustomer = async (req: any, res: any) =>
|
||||
});
|
||||
|
||||
let getInvoices = apiVersion < APIVersion.v1_1;
|
||||
if (getInvoices) {
|
||||
expandArray.push(CusExpand.Invoices);
|
||||
}
|
||||
if (getInvoices) expandArray.push(CusExpand.Invoices);
|
||||
|
||||
logger.info(`getting customer ${customerId} for org ${org.slug}`);
|
||||
const startTime = Date.now();
|
||||
@@ -46,16 +38,6 @@ export const handleGetCustomer = async (req: any, res: any) =>
|
||||
logger,
|
||||
});
|
||||
|
||||
// const customer = await CusService.getFull({
|
||||
// db,
|
||||
// idOrInternalId: customerId,
|
||||
// orgId: org.id,
|
||||
// env: env,
|
||||
// withEntities: true,
|
||||
// expand: expandArray,
|
||||
// allowNotFound: true,
|
||||
// withSubs: true,
|
||||
// });
|
||||
logger.info(`get customer took ${Date.now() - startTime}ms`);
|
||||
|
||||
if (!customer) {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -100,6 +100,6 @@ export const handleDecreaseAndTransfer = async ({
|
||||
},
|
||||
product
|
||||
),
|
||||
sendWebhook: false,
|
||||
scenario: AttachScenario.New,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,130 +1,124 @@
|
||||
import {
|
||||
Organization,
|
||||
Feature,
|
||||
FullProduct,
|
||||
EntitlementWithFeature,
|
||||
AllowanceType,
|
||||
getFeatureName,
|
||||
FeatureType,
|
||||
Price,
|
||||
type EntitlementWithFeature,
|
||||
type Feature, type FullProduct, type Organization,
|
||||
type Price
|
||||
} from "@autumn/shared";
|
||||
import { mapToProductItems } from "@/internal/products/productV2Utils.js";
|
||||
import { getProductItemDisplay } from "../products/productUtils/productResponseUtils/getProductItemDisplay.js";
|
||||
import { getEntRelatedPrice } from "../products/entitlements/entitlementUtils.js";
|
||||
import { isFeatureItem } from "../products/product-items/productItemUtils/getItemType.js";
|
||||
import { getProductItemDisplay } from "../products/productUtils/productResponseUtils/getProductItemDisplay.js";
|
||||
|
||||
export const buildInvoiceMemo = async ({
|
||||
org,
|
||||
product,
|
||||
products,
|
||||
features,
|
||||
org,
|
||||
product,
|
||||
products,
|
||||
features,
|
||||
}: {
|
||||
org: Organization;
|
||||
product?: FullProduct;
|
||||
products?: FullProduct[];
|
||||
features: Feature[];
|
||||
org: Organization;
|
||||
product?: FullProduct;
|
||||
products?: FullProduct[];
|
||||
features: Feature[];
|
||||
}): Promise<string> => {
|
||||
if (product) {
|
||||
const items = mapToProductItems({
|
||||
prices: product.prices,
|
||||
entitlements: product.entitlements,
|
||||
features,
|
||||
});
|
||||
if (product) {
|
||||
const items = mapToProductItems({
|
||||
prices: product.prices,
|
||||
entitlements: product.entitlements,
|
||||
features,
|
||||
});
|
||||
|
||||
const itemsToDisplay = ["Included features:"];
|
||||
for (const item of items) {
|
||||
if (!item.feature_id) continue;
|
||||
const display = getProductItemDisplay({
|
||||
item,
|
||||
features,
|
||||
currency: org.default_currency,
|
||||
});
|
||||
if (display?.primary_text) itemsToDisplay.push(display.primary_text);
|
||||
}
|
||||
const itemsToDisplay = ["Included features:"];
|
||||
for (const item of items) {
|
||||
if (!item.feature_id) continue;
|
||||
const display = getProductItemDisplay({
|
||||
item,
|
||||
features,
|
||||
currency: org.default_currency,
|
||||
});
|
||||
if (display?.primary_text) itemsToDisplay.push(display.primary_text);
|
||||
}
|
||||
|
||||
return itemsToDisplay.join("\n");
|
||||
} else if (products) {
|
||||
const itemsToDisplay = ["Included features:"];
|
||||
return itemsToDisplay.join("\n");
|
||||
} else if (products) {
|
||||
const itemsToDisplay = ["Included features:"];
|
||||
|
||||
for (const p of products) {
|
||||
const items = mapToProductItems({
|
||||
prices: p.prices,
|
||||
entitlements: p.entitlements,
|
||||
features,
|
||||
});
|
||||
for (const p of products) {
|
||||
const items = mapToProductItems({
|
||||
prices: p.prices,
|
||||
entitlements: p.entitlements,
|
||||
features,
|
||||
});
|
||||
|
||||
console.log(
|
||||
"Items: %s",
|
||||
items.map((i) => i.feature_id)
|
||||
);
|
||||
console.log(
|
||||
"Items: %s",
|
||||
items.map((i) => i.feature_id),
|
||||
);
|
||||
|
||||
for (const item of items) {
|
||||
if (!item.feature_id) continue;
|
||||
const display = getProductItemDisplay({
|
||||
item,
|
||||
features,
|
||||
currency: org.default_currency,
|
||||
});
|
||||
console.log(
|
||||
"Display for item %s: %s",
|
||||
item.feature_id,
|
||||
display?.primary_text
|
||||
);
|
||||
if (display?.primary_text) itemsToDisplay.push(display.primary_text);
|
||||
}
|
||||
}
|
||||
for (const item of items) {
|
||||
if (!item.feature_id) continue;
|
||||
const display = getProductItemDisplay({
|
||||
item,
|
||||
features,
|
||||
currency: org.default_currency,
|
||||
});
|
||||
console.log(
|
||||
"Display for item %s: %s",
|
||||
item.feature_id,
|
||||
display?.primary_text,
|
||||
);
|
||||
if (display?.primary_text) itemsToDisplay.push(display.primary_text);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("Items to display: %s", itemsToDisplay);
|
||||
console.log("Items to display: %s", itemsToDisplay);
|
||||
|
||||
let memo = itemsToDisplay.join("\n");
|
||||
if (memo.length > 490) {
|
||||
memo = memo.slice(0, 490) + "...";
|
||||
}
|
||||
return memo;
|
||||
} else return "";
|
||||
let memo = itemsToDisplay.join("\n");
|
||||
if (memo.length > 490) {
|
||||
memo = memo.slice(0, 490) + "...";
|
||||
}
|
||||
return memo;
|
||||
} else return "";
|
||||
};
|
||||
|
||||
export const buildInvoiceMemoFromEntitlements = async ({
|
||||
org,
|
||||
entitlements,
|
||||
prices,
|
||||
features,
|
||||
logger,
|
||||
org,
|
||||
entitlements,
|
||||
prices,
|
||||
features,
|
||||
logger,
|
||||
}: {
|
||||
org: Organization;
|
||||
entitlements: EntitlementWithFeature[];
|
||||
prices: Price[];
|
||||
features: Feature[];
|
||||
logger: any;
|
||||
org: Organization;
|
||||
entitlements: EntitlementWithFeature[];
|
||||
prices: Price[];
|
||||
features: Feature[];
|
||||
logger: any;
|
||||
}) => {
|
||||
// Get item from price and ent
|
||||
const items = mapToProductItems({
|
||||
entitlements,
|
||||
prices,
|
||||
features,
|
||||
});
|
||||
// Get item from price and ent
|
||||
const items = mapToProductItems({
|
||||
entitlements,
|
||||
prices,
|
||||
features,
|
||||
});
|
||||
|
||||
if (items.filter(isFeatureItem).length === 0) return "";
|
||||
if (items.filter(isFeatureItem).length === 0) return "";
|
||||
|
||||
const itemsToDisplay = ["Included:"];
|
||||
const itemsToDisplay = ["Included:"];
|
||||
|
||||
for (const item of items) {
|
||||
if (!isFeatureItem(item)) continue;
|
||||
for (const item of items) {
|
||||
if (!isFeatureItem(item)) continue;
|
||||
|
||||
const display = getProductItemDisplay({
|
||||
item,
|
||||
features,
|
||||
currency: org.default_currency,
|
||||
});
|
||||
const display = getProductItemDisplay({
|
||||
item,
|
||||
features,
|
||||
currency: org.default_currency,
|
||||
});
|
||||
|
||||
itemsToDisplay.push(
|
||||
`- ${display?.primary_text}${display?.secondary_text ? ` ${display?.secondary_text}` : ""}`
|
||||
);
|
||||
}
|
||||
itemsToDisplay.push(
|
||||
`- ${display?.primary_text}${display?.secondary_text ? ` ${display?.secondary_text}` : ""}`,
|
||||
);
|
||||
}
|
||||
|
||||
let memo = itemsToDisplay.join("\n");
|
||||
if (memo.length > 490) {
|
||||
memo = memo.slice(0, 490) + "...";
|
||||
}
|
||||
return memo;
|
||||
let memo = itemsToDisplay.join("\n");
|
||||
if (memo.length > 490) {
|
||||
memo = `${memo.slice(0, 490)}...`;
|
||||
}
|
||||
return memo;
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -44,22 +44,20 @@ const deleteStripeWebhooks = async ({
|
||||
org: Organization;
|
||||
logger: any;
|
||||
}) => {
|
||||
if (org.stripe_config) {
|
||||
try {
|
||||
await deleteStripeWebhook({
|
||||
org: org,
|
||||
env: AppEnv.Sandbox,
|
||||
});
|
||||
try {
|
||||
await deleteStripeWebhook({
|
||||
org: org,
|
||||
env: AppEnv.Sandbox,
|
||||
});
|
||||
|
||||
await deleteStripeWebhook({
|
||||
org: org,
|
||||
env: AppEnv.Live,
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error(
|
||||
`Failed to delete stripe webhooks for ${org.id}, ${org.slug}. ${error.message})`
|
||||
);
|
||||
}
|
||||
await deleteStripeWebhook({
|
||||
org: org,
|
||||
env: AppEnv.Live,
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error(
|
||||
`Failed to delete stripe webhooks for ${org.id}, ${org.slug}. ${error.message})`
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,198 +1,214 @@
|
||||
import RecaseError from "@/utils/errorUtils.js";
|
||||
import { generateId, notNullish } from "@/utils/genUtils.js";
|
||||
import {
|
||||
CreateFreeTrial,
|
||||
type CreateFreeTrial,
|
||||
CreateFreeTrialSchema,
|
||||
ErrCode,
|
||||
FreeTrial,
|
||||
type FreeProductConfig,
|
||||
type FreeTrial,
|
||||
FreeTrialDuration,
|
||||
Price,
|
||||
type Price
|
||||
} from "@autumn/shared";
|
||||
import { addDays, addMinutes, addMonths, addYears } from "date-fns";
|
||||
import { FreeTrialService } from "./FreeTrialService.js";
|
||||
import { DrizzleCli } from "@/db/initDrizzle.js";
|
||||
import type { DrizzleCli } from "@/db/initDrizzle.js";
|
||||
import { CusProductService } from "@/internal/customers/cusProducts/CusProductService.js";
|
||||
import { isFreeProduct, isOneOff } from "../productUtils.js";
|
||||
import RecaseError from "@/utils/errorUtils.js";
|
||||
import { generateId } from "@/utils/genUtils.js";
|
||||
import { isOneOff } from "../productUtils.js";
|
||||
import { FreeTrialService } from "./FreeTrialService.js";
|
||||
|
||||
export const validateOneOffTrial = async ({
|
||||
prices,
|
||||
freeTrial,
|
||||
prices,
|
||||
freeTrial,
|
||||
}: {
|
||||
prices: Price[];
|
||||
freeTrial: FreeTrial | null;
|
||||
prices: Price[];
|
||||
freeTrial: FreeTrial | null;
|
||||
}) => {
|
||||
if(isOneOff(prices) && freeTrial) {
|
||||
throw new RecaseError({
|
||||
message: "One-off products cannot have a free trial",
|
||||
code: ErrCode.InvalidRequest,
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (isOneOff(prices) && freeTrial) {
|
||||
throw new RecaseError({
|
||||
message: "One-off products cannot have a free trial",
|
||||
code: ErrCode.InvalidRequest,
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const validateAndInitFreeTrial = ({
|
||||
freeTrial,
|
||||
internalProductId,
|
||||
isCustom = false,
|
||||
freeTrial,
|
||||
internalProductId,
|
||||
isCustom = false,
|
||||
}: {
|
||||
freeTrial: CreateFreeTrial;
|
||||
internalProductId: string;
|
||||
isCustom?: boolean;
|
||||
freeTrial: CreateFreeTrial;
|
||||
internalProductId: string;
|
||||
isCustom?: boolean;
|
||||
}): FreeTrial => {
|
||||
const freeTrialSchema = CreateFreeTrialSchema.parse(freeTrial);
|
||||
const freeTrialSchema = CreateFreeTrialSchema.parse(freeTrial);
|
||||
|
||||
return {
|
||||
...freeTrialSchema,
|
||||
id: generateId("ft"),
|
||||
created_at: Date.now(),
|
||||
duration: freeTrial.duration || FreeTrialDuration.Day,
|
||||
internal_product_id: internalProductId,
|
||||
is_custom: isCustom,
|
||||
card_required: freeTrial.card_required ?? true,
|
||||
};
|
||||
return {
|
||||
...freeTrialSchema,
|
||||
id: generateId("ft"),
|
||||
created_at: Date.now(),
|
||||
duration: freeTrial.duration || FreeTrialDuration.Day,
|
||||
internal_product_id: internalProductId,
|
||||
is_custom: isCustom,
|
||||
card_required: freeTrial.card_required ?? true,
|
||||
};
|
||||
};
|
||||
|
||||
export const freeTrialsAreSame = ({
|
||||
ft1,
|
||||
ft2,
|
||||
ft1,
|
||||
ft2,
|
||||
}: {
|
||||
ft1?: FreeTrial | CreateFreeTrial | null;
|
||||
ft2?: FreeTrial | CreateFreeTrial | null;
|
||||
ft1?: FreeTrial | CreateFreeTrial | null;
|
||||
ft2?: FreeTrial | CreateFreeTrial | null;
|
||||
}) => {
|
||||
if (!ft1 && !ft2) return true;
|
||||
if (!ft1 || !ft2) return false;
|
||||
return (
|
||||
ft1.length === ft2.length &&
|
||||
ft1.unique_fingerprint === ft2.unique_fingerprint &&
|
||||
ft1.duration === ft2.duration &&
|
||||
ft1.card_required === ft2.card_required
|
||||
);
|
||||
if (!ft1 && !ft2) return true;
|
||||
if (!ft1 || !ft2) return false;
|
||||
return (
|
||||
ft1.length === ft2.length &&
|
||||
ft1.unique_fingerprint === ft2.unique_fingerprint &&
|
||||
ft1.duration === ft2.duration &&
|
||||
ft1.card_required === ft2.card_required
|
||||
);
|
||||
};
|
||||
|
||||
export const freeTrialToStripeTimestamp = ({
|
||||
freeTrial,
|
||||
now,
|
||||
freeTrial,
|
||||
now,
|
||||
}: {
|
||||
freeTrial: FreeTrial | null | undefined;
|
||||
now?: number | undefined;
|
||||
freeTrial: FreeTrial | null | undefined;
|
||||
now?: number | undefined;
|
||||
}) => {
|
||||
now = now || Date.now();
|
||||
now = now || Date.now();
|
||||
|
||||
if (!freeTrial) return undefined;
|
||||
if (!freeTrial) return undefined;
|
||||
|
||||
let duration = freeTrial.duration || FreeTrialDuration.Day;
|
||||
let length = freeTrial.length;
|
||||
const duration = freeTrial.duration || FreeTrialDuration.Day;
|
||||
const length = freeTrial.length;
|
||||
|
||||
let trialEnd: Date;
|
||||
if (duration === FreeTrialDuration.Day) {
|
||||
trialEnd = addDays(new Date(now), length);
|
||||
} else if (duration === FreeTrialDuration.Month) {
|
||||
trialEnd = addMonths(new Date(now), length);
|
||||
} else if (duration === FreeTrialDuration.Year) {
|
||||
trialEnd = addYears(new Date(now), length);
|
||||
} else {
|
||||
throw new RecaseError({
|
||||
message: `Invalid free trial duration: ${duration}`,
|
||||
code: "invalid_free_trial_duration",
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
let trialEnd: Date;
|
||||
if (duration === FreeTrialDuration.Day) {
|
||||
trialEnd = addDays(new Date(now), length);
|
||||
} else if (duration === FreeTrialDuration.Month) {
|
||||
trialEnd = addMonths(new Date(now), length);
|
||||
} else if (duration === FreeTrialDuration.Year) {
|
||||
trialEnd = addYears(new Date(now), length);
|
||||
} else {
|
||||
throw new RecaseError({
|
||||
message: `Invalid free trial duration: ${duration}`,
|
||||
code: "invalid_free_trial_duration",
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
// trialEnd = addMinutes(trialEnd, 5);
|
||||
trialEnd = addMinutes(trialEnd, 10);
|
||||
// trialEnd = addMinutes(trialEnd, 5);
|
||||
trialEnd = addMinutes(trialEnd, 10);
|
||||
|
||||
return Math.ceil(trialEnd.getTime() / 1000);
|
||||
return Math.ceil(trialEnd.getTime() / 1000);
|
||||
};
|
||||
|
||||
export const rewardTrialToStripeTimestamp = ({
|
||||
rewardTrial,
|
||||
now,
|
||||
}: {
|
||||
rewardTrial: FreeProductConfig | null | undefined;
|
||||
now?: number | undefined;
|
||||
}) => {
|
||||
now = now || Date.now();
|
||||
if (!rewardTrial) return undefined;
|
||||
const length = rewardTrial.duration_value;
|
||||
const trialEnd: Date = addMonths(new Date(now), length);
|
||||
|
||||
return Math.ceil(trialEnd.getTime() / 1000);
|
||||
};
|
||||
|
||||
export const getFreeTrialAfterFingerprint = async ({
|
||||
db,
|
||||
freeTrial,
|
||||
productId,
|
||||
fingerprint,
|
||||
internalCustomerId,
|
||||
multipleAllowed,
|
||||
db,
|
||||
freeTrial,
|
||||
productId,
|
||||
fingerprint,
|
||||
internalCustomerId,
|
||||
multipleAllowed,
|
||||
}: {
|
||||
db: DrizzleCli;
|
||||
freeTrial: FreeTrial | null | undefined;
|
||||
productId: string;
|
||||
fingerprint: string | null | undefined;
|
||||
internalCustomerId: string;
|
||||
multipleAllowed: boolean;
|
||||
db: DrizzleCli;
|
||||
freeTrial: FreeTrial | null | undefined;
|
||||
productId: string;
|
||||
fingerprint: string | null | undefined;
|
||||
internalCustomerId: string;
|
||||
multipleAllowed: boolean;
|
||||
}): Promise<FreeTrial | null> => {
|
||||
if (!freeTrial) return null;
|
||||
if (!freeTrial) return null;
|
||||
|
||||
if (multipleAllowed) {
|
||||
return freeTrial;
|
||||
}
|
||||
if (multipleAllowed) {
|
||||
return freeTrial;
|
||||
}
|
||||
|
||||
let uniqueFreeTrial: FreeTrial | null = freeTrial;
|
||||
let uniqueFreeTrial: FreeTrial | null = freeTrial;
|
||||
|
||||
const data = await CusProductService.getByFingerprint({
|
||||
db,
|
||||
productId,
|
||||
internalCustomerId,
|
||||
fingerprint: uniqueFreeTrial.unique_fingerprint ? fingerprint! : undefined,
|
||||
});
|
||||
const data = await CusProductService.getByFingerprint({
|
||||
db,
|
||||
productId,
|
||||
internalCustomerId,
|
||||
fingerprint: uniqueFreeTrial.unique_fingerprint ? fingerprint! : undefined,
|
||||
});
|
||||
|
||||
const exists = data && data.length > 0;
|
||||
const exists = data && data.length > 0;
|
||||
|
||||
if (exists) {
|
||||
console.log("Free trial fingerprint exists");
|
||||
uniqueFreeTrial = null;
|
||||
}
|
||||
if (exists) {
|
||||
console.log("Free trial fingerprint exists");
|
||||
uniqueFreeTrial = null;
|
||||
}
|
||||
|
||||
return uniqueFreeTrial;
|
||||
return uniqueFreeTrial;
|
||||
};
|
||||
|
||||
export const handleNewFreeTrial = async ({
|
||||
db,
|
||||
newFreeTrial,
|
||||
curFreeTrial,
|
||||
internalProductId,
|
||||
isCustom = false,
|
||||
product,
|
||||
db,
|
||||
newFreeTrial,
|
||||
curFreeTrial,
|
||||
internalProductId,
|
||||
isCustom = false,
|
||||
product,
|
||||
}: {
|
||||
db: DrizzleCli;
|
||||
newFreeTrial: CreateFreeTrial | null;
|
||||
curFreeTrial: FreeTrial | null | undefined;
|
||||
internalProductId: string;
|
||||
isCustom: boolean;
|
||||
product?: any; // Add product parameter for validation
|
||||
db: DrizzleCli;
|
||||
newFreeTrial: CreateFreeTrial | null;
|
||||
curFreeTrial: FreeTrial | null | undefined;
|
||||
internalProductId: string;
|
||||
isCustom: boolean;
|
||||
product?: any; // Add product parameter for validation
|
||||
}) => {
|
||||
// If new free trial is null
|
||||
if (!newFreeTrial) {
|
||||
if (!isCustom && curFreeTrial) {
|
||||
await FreeTrialService.delete({
|
||||
db,
|
||||
id: curFreeTrial.id,
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}
|
||||
// If new free trial is null
|
||||
if (!newFreeTrial) {
|
||||
if (!isCustom && curFreeTrial) {
|
||||
await FreeTrialService.delete({
|
||||
db,
|
||||
id: curFreeTrial.id,
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (freeTrialsAreSame({ ft1: curFreeTrial, ft2: newFreeTrial })) {
|
||||
return curFreeTrial;
|
||||
}
|
||||
if (freeTrialsAreSame({ ft1: curFreeTrial, ft2: newFreeTrial })) {
|
||||
return curFreeTrial;
|
||||
}
|
||||
|
||||
const createdFreeTrial = validateAndInitFreeTrial({
|
||||
freeTrial: newFreeTrial,
|
||||
internalProductId,
|
||||
isCustom,
|
||||
});
|
||||
const createdFreeTrial = validateAndInitFreeTrial({
|
||||
freeTrial: newFreeTrial,
|
||||
internalProductId,
|
||||
isCustom,
|
||||
});
|
||||
|
||||
if (isCustom && newFreeTrial) {
|
||||
await FreeTrialService.insert({
|
||||
db,
|
||||
data: createdFreeTrial,
|
||||
});
|
||||
} else if (!isCustom) {
|
||||
createdFreeTrial.id = curFreeTrial?.id || createdFreeTrial.id;
|
||||
if (isCustom && newFreeTrial) {
|
||||
await FreeTrialService.insert({
|
||||
db,
|
||||
data: createdFreeTrial,
|
||||
});
|
||||
} else if (!isCustom) {
|
||||
createdFreeTrial.id = curFreeTrial?.id || createdFreeTrial.id;
|
||||
|
||||
await FreeTrialService.upsert({
|
||||
db,
|
||||
data: createdFreeTrial,
|
||||
});
|
||||
}
|
||||
await FreeTrialService.upsert({
|
||||
db,
|
||||
data: createdFreeTrial,
|
||||
});
|
||||
}
|
||||
|
||||
return createdFreeTrial;
|
||||
return createdFreeTrial;
|
||||
};
|
||||
|
||||
@@ -44,11 +44,6 @@ export const handleListProductsBeta = async (req: any, res: any) =>
|
||||
})(),
|
||||
]);
|
||||
|
||||
console.log(
|
||||
"Products:",
|
||||
products.map((p) => `${p.internal_id} - ${p.id} - ${p.archived}`)
|
||||
);
|
||||
|
||||
if (req.query.v1_schema === "true") {
|
||||
res.status(200).json({
|
||||
list: products,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user