merge dev

This commit is contained in:
Ayush Rodrigues
2025-11-27 16:07:47 +00:00
244 changed files with 6802 additions and 3220 deletions

View File

@@ -1,36 +1,95 @@
name: Build
name: Build and Push to ECR
on:
pull_request:
branches: [main, staging, dev]
push:
branches: [main, staging, dev]
branches:
- main
- staging
workflow_dispatch:
inputs:
tag:
description: 'Manual deploy'
required: false
default: 'manual'
env:
AWS_REGION: us-west-2 # Change to your AWS region
ECR_REPOSITORY: autumn # Change to your ECR repository name
jobs:
build:
name: Build All Packages
runs-on: ubuntu-latest
build-and-push:
name: Build and Push Docker Image
runs-on: blacksmith-16vcpu-ubuntu-2404
permissions:
id-token: write
contents: read
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v2
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
bun-version: latest
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Build shared package
run: bun run build
working-directory: shared
- name: Build server package
run: bun run build
working-directory: server
- name: Build vite package
run: bun run build
working-directory: vite
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: ${{ env.AWS_REGION }}
- name: Login to Amazon ECR
id: login-ecr
uses: aws-actions/amazon-ecr-login@v2
- name: Extract metadata for Docker
id: meta
run: |
BRANCH_NAME=${GITHUB_REF#refs/heads/}
COMMIT_SHA=${GITHUB_SHA::8}
# Sanitize branch name for Docker tag (replace / with -)
SAFE_BRANCH=$(echo $BRANCH_NAME | sed 's/\//-/g')
echo "branch=$SAFE_BRANCH" >> $GITHUB_OUTPUT
echo "sha=$COMMIT_SHA" >> $GITHUB_OUTPUT
# Set custom tag if provided
if [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ -n "${{ github.event.inputs.tag }}" ]; then
echo "custom_tag=${{ github.event.inputs.tag }}" >> $GITHUB_OUTPUT
fi
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push Docker image
env:
ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
IMAGE_TAG_SHA: ${{ steps.meta.outputs.sha }}
IMAGE_TAG_BRANCH: ${{ steps.meta.outputs.branch }}
run: |
# Build single combined tag: branch-sha
COMBINED_TAG="${IMAGE_TAG_BRANCH}-${IMAGE_TAG_SHA}"
TAGS="${ECR_REGISTRY}/${ECR_REPOSITORY}:${COMBINED_TAG}"
# Build and push
docker buildx build \
--platform linux/amd64 \
--push \
--provenance=false \
--sbom=false \
--cache-from type=registry,ref=${ECR_REGISTRY}/${ECR_REPOSITORY}:${IMAGE_TAG_BRANCH} \
--cache-to type=inline \
--tag ${TAGS//,/ --tag } \
-f docker/Dockerfile \
.
- name: Output image details
env:
ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
IMAGE_TAG_SHA: ${{ steps.meta.outputs.sha }}
IMAGE_TAG_BRANCH: ${{ steps.meta.outputs.branch }}
run: |
COMBINED_TAG="${IMAGE_TAG_BRANCH}-${IMAGE_TAG_SHA}"
echo "✅ Image pushed successfully!"
echo "📦 Repository: ${ECR_REGISTRY}/${ECR_REPOSITORY}"
echo "🏷️ Tag: ${COMBINED_TAG}"

25
docker/Dockerfile Normal file
View File

@@ -0,0 +1,25 @@
FROM oven/bun:1.3.2
WORKDIR /app
# Copy root package files
COPY package.json .
COPY bun.lock .
# Copy workspace package.json files
COPY server/package.json ./server/package.json
COPY shared/package.json ./shared/package.json
COPY vite/package.json ./vite/package.json
COPY scripts/package.json ./scripts/package.json
# Install dependencies
RUN bun install --ignore-scripts
# Copy rest of the code
COPY . .
ENV NODE_ENV=production
EXPOSE 8080
WORKDIR /app/server
CMD ["bun", "start"]

37
firelens.conf Normal file
View File

@@ -0,0 +1,37 @@
[FILTER]
Name modify
Match *
Condition Key_value_matches container_name .*-server-.*
Add type server
[FILTER]
Name modify
Match *
Condition Key_value_matches container_name .*-workers-.*
Add type workers
[FILTER]
Name modify
Match *
Condition Key_value_matches container_name .*-cron-.*
Add type cron
[FILTER]
Name modify
Match *
Add region ${AWS_REGION}
[OUTPUT]
Name http
Match *
Host us-east-1.aws.edge.axiom.co
Port 443
URI /v1/ingest/ecs
Format json
TLS On
json_date_key _time
json_date_format iso8601
Header Authorization Bearer ${AXIOM_TOKEN}
Header Content-Type application/json
retry_limit 5
net.dns.mode TCP

View File

@@ -14,6 +14,7 @@ BUN_PARALLEL_COMPACT \
'server/tests/balances/check/credit-systems' \
'server/tests/balances/check/misc' \
'server/tests/balances/check/prepaid' \
'server/tests/balances/check/send-event' \
'server/tests/balances/track/basic' \
'server/tests/balances/track/credit-systems' \
'server/tests/balances/track/entity-products' \

View File

@@ -9,7 +9,6 @@ BUN_PARALLEL_COMPACT \
'server/tests/attach/downgrade' \
'server/tests/attach/free' \
'server/tests/attach/addOn' \
'server/tests/attach/entities' \
'server/tests/attach/checkout' \
'server/tests/attach/misc' \
--max=6 \

View File

@@ -3,22 +3,23 @@
# Source shared configuration
source "$(dirname "$0")/config.sh"
BUN_PARALLEL_COMPACT 'server/tests/advanced/rollovers'
BUN_PARALLEL_COMPACT \
'server/tests/advanced/coupons' \
'server/tests/advanced/misc' \
'server/tests/attach/updateQuantity' \
'server/tests/attach/multiProduct' \
'server/tests/advanced/multiFeature' \
'server/tests/advanced/referrals' \
'server/tests/advanced/rollovers' \
'server/tests/advanced/customInterval' \
'server/tests/advanced/usageLimit' \
--max=6
# BUN_PARALLEL_COMPACT \
# 'server/tests/advanced/coupons' \
# 'server/tests/advanced/misc' \
# 'server/tests/attach/updateQuantity' \
# 'server/tests/attach/multiProduct' \
# 'server/tests/advanced/multiFeature' \
# 'server/tests/advanced/referrals' \
# 'server/tests/advanced/rollovers' \
# 'server/tests/advanced/customInterval' \
# 'server/tests/advanced/usageLimit' \
# --max=6
BUN_PARALLEL_COMPACT \
'server/tests/advanced/usage'
# 'server/tests/crud/plan'
# BUN_PARALLEL_COMPACT \
# 'server/tests/advanced/usage'
# # 'server/tests/crud/plan'
# 'server/tests/advanced/referrals/paid' \
# # 'server/tests/advanced/referrals/paid' \

View File

@@ -21,8 +21,8 @@
"recommended": true,
"suspicious": {
"noExportsInTest": "off",
"noExplicitAny": "off",
"noImplicitAnyLet": "off"
"noExplicitAny": "on",
"noImplicitAnyLet": "on"
},
"complexity": {
"noStaticOnlyClass": "off"

View File

@@ -0,0 +1,17 @@
import { CusEntService } from "../src/internal/customers/cusProducts/cusEnts/CusEntitlementService";
import { initDrizzle } from "../src/db/initDrizzle";
import { clearCusEntsFromCache } from "../src/cron/cronUtils";
const main = async () => {
const { db } = initDrizzle();
const cusEnts = await CusEntService.getActiveResetPassed({
db,
batchSize: 500,
});
await clearCusEntsFromCache({ cusEnts });
};
await main();
process.exit(0);

View File

@@ -1,11 +1,15 @@
import "dotenv/config";
import { loadLocalEnv } from "./src/utils/envUtils";
import Stripe from "stripe";
loadLocalEnv();
const main = async () => {
const stripe = new Stripe(process.env.STRIPE_SANDBOX_SECRET_KEY || "");
const result = await stripe.webhookEndpoints.create({
url: "https://express.dev.useautumn.com/webhooks/connect/sandbox",
url: `${process.env.STRIPE_WEBHOOK_URL}/webhooks/connect/sandbox`,
enabled_events: [
"checkout.session.completed",
"customer.subscription.created",

View File

@@ -0,0 +1,103 @@
-- batchDeleteCustomers.lua
-- Atomically deletes multiple customers and all their associated entity caches
-- ARGV[1]: JSON array of {orgId, env, customerId} objects
-- Returns: number of keys deleted
local customersJson = ARGV[1]
local customers = cjson.decode(customersJson)
local allKeysToDelete = {}
-- Helper function to add balance-related keys for a cache key
local function addBalanceKeys(keysToDelete, cacheKey, featureIds)
if not featureIds or #featureIds == 0 then
return
end
for _, featureId in ipairs(featureIds) do
local balanceKey = buildBalanceCacheKey(cacheKey, featureId)
table.insert(keysToDelete, balanceKey)
-- Get the balance HSET to find breakdown/rollover counts
local balanceData = redis.call("HGETALL", balanceKey)
if balanceData and #balanceData > 0 then
-- Convert array to hash table
local balanceHash = {}
for i = 1, #balanceData, 2 do
balanceHash[balanceData[i]] = balanceData[i + 1]
end
-- Delete rollover keys
local rolloverCount = tonumber(balanceHash["_rollover_count"]) or 0
for i = 0, rolloverCount - 1 do
table.insert(keysToDelete, buildRolloverCacheKey(cacheKey, featureId, i))
end
-- Delete breakdown keys
local breakdownCount = tonumber(balanceHash["_breakdown_count"]) or 0
for i = 0, breakdownCount - 1 do
table.insert(keysToDelete, buildBreakdownCacheKey(cacheKey, featureId, i))
end
end
end
end
-- Process each customer
for _, customerInfo in ipairs(customers) do
local orgId = customerInfo.orgId
local env = customerInfo.env
local customerId = customerInfo.customerId
-- Build versioned cache key using shared utility
local cacheKey = buildCustomerCacheKey(orgId, env, customerId)
-- Get the customer base JSON to find entity and feature IDs
local baseJson = redis.call("GET", cacheKey)
-- Skip if customer not in cache
if baseJson then
table.insert(allKeysToDelete, cacheKey)
local success, customer = pcall(cjson.decode, baseJson)
if success and customer then
local entityIds = customer._entityIds or {}
local balanceFeatureIds = customer._balanceFeatureIds or {}
-- Add customer balance keys (with rollover/breakdown)
addBalanceKeys(allKeysToDelete, cacheKey, balanceFeatureIds)
-- Process each entity
for _, entityId in ipairs(entityIds) do
local entityCacheKey = buildEntityCacheKey(orgId, env, customerId, entityId)
table.insert(allKeysToDelete, entityCacheKey)
-- Get entity to find its feature IDs
local entityJson = redis.call("GET", entityCacheKey)
if entityJson then
local entitySuccess, entity = pcall(cjson.decode, entityJson)
if entitySuccess and entity then
local entityFeatureIds = entity._balanceFeatureIds or {}
-- Add entity balance keys (with rollover/breakdown)
addBalanceKeys(allKeysToDelete, entityCacheKey, entityFeatureIds)
end
end
end
end
end
end
-- Use UNLINK instead of DEL for async deletion (non-blocking)
local deletedCount = 0
if #allKeysToDelete > 0 then
-- UNLINK has a limit, so batch in chunks of 1000 keys
local chunkSize = 1000
for i = 1, #allKeysToDelete, chunkSize do
local chunk = {}
for j = i, math.min(i + chunkSize - 1, #allKeysToDelete) do
table.insert(chunk, allKeysToDelete[j])
end
deletedCount = deletedCount + redis.call("UNLINK", unpack(chunk))
end
end
return deletedCount

View File

@@ -9,37 +9,72 @@ local orgId = ARGV[1]
local env = ARGV[2]
local customerId = ARGV[3]
-- Helper function to add balance-related keys for a cache key
local function addBalanceKeys(keysToDelete, cacheKey, featureIds)
for _, featureId in ipairs(featureIds) do
local balanceKey = buildBalanceCacheKey(cacheKey, featureId)
table.insert(keysToDelete, balanceKey)
-- Get the balance HSET to find breakdown/rollover counts
local balanceData = redis.call("HGETALL", balanceKey)
if balanceData and #balanceData > 0 then
-- Convert array to hash table
local balanceHash = {}
for i = 1, #balanceData, 2 do
balanceHash[balanceData[i]] = balanceData[i + 1]
end
-- Delete rollover keys
local rolloverCount = tonumber(balanceHash["_rollover_count"] or 0)
for i = 0, rolloverCount - 1 do
table.insert(keysToDelete, buildRolloverCacheKey(cacheKey, featureId, i))
end
-- Delete breakdown keys
local breakdownCount = tonumber(balanceHash["_breakdown_count"] or 0)
for i = 0, breakdownCount - 1 do
table.insert(keysToDelete, buildBreakdownCacheKey(cacheKey, featureId, i))
end
end
end
end
-- Build versioned cache key using shared utility
local cacheKey = buildCustomerCacheKey(orgId, env, customerId)
local basePattern = cacheKey .. "*"
local keysToDelete = {}
-- Scan for all keys matching the pattern
-- This includes the customer base key and ALL entity keys under it
local cursor = "0"
repeat
local result = redis.call("SCAN", cursor, "MATCH", basePattern, "COUNT", 100)
cursor = result[1]
local keys = result[2]
-- Get the customer base JSON to find entity and feature IDs
local baseJson = redis.call("GET", cacheKey)
local keysToDelete = {cacheKey}
if baseJson then
local customer = cjson.decode(baseJson)
local entityIds = customer._entityIds or {}
local balanceFeatureIds = customer._balanceFeatureIds or {}
for _, key in ipairs(keys) do
table.insert(keysToDelete, key)
-- Add customer balance keys (with rollover/breakdown)
addBalanceKeys(keysToDelete, cacheKey, balanceFeatureIds)
-- Process each entity
for _, entityId in ipairs(entityIds) do
local entityCacheKey = buildEntityCacheKey(orgId, env, customerId, entityId)
table.insert(keysToDelete, entityCacheKey)
-- Get entity to find its feature IDs
local entityJson = redis.call("GET", entityCacheKey)
if entityJson then
local entity = cjson.decode(entityJson)
local entityFeatureIds = entity._balanceFeatureIds or {}
-- Add entity balance keys (with rollover/breakdown)
addBalanceKeys(keysToDelete, entityCacheKey, entityFeatureIds)
end
end
until cursor == "0"
end
-- Delete all keys in one atomic operation
-- Use UNLINK instead of DEL for async deletion (non-blocking)
local deletedCount = 0
if #keysToDelete > 0 then
-- Redis DEL can handle multiple keys, but has argument limits
-- So we batch delete in chunks of 1000
local chunkSize = 1000
for i = 1, #keysToDelete, chunkSize do
local chunk = {}
for j = i, math.min(i + chunkSize - 1, #keysToDelete) do
table.insert(chunk, keysToDelete[j])
end
deletedCount = deletedCount + redis.call("DEL", unpack(chunk))
end
deletedCount = redis.call("UNLINK", unpack(keysToDelete))
end
return deletedCount

View File

@@ -56,6 +56,7 @@ local baseCustomer = {
env = customerData.env,
metadata = customerData.metadata,
subscriptions = customerData.subscriptions,
scheduled_subscriptions = customerData.scheduled_subscriptions,
invoices = customerData.invoices,
legacyData = customerData.legacyData,
entities = customerData.entities,

View File

@@ -1,14 +1,16 @@
-- setSubscriptions.lua
-- Updates only the subscriptions array in the customer cache
-- Updates both subscriptions and scheduled_subscriptions arrays in the customer cache
-- ARGV[1]: serialized subscriptions array JSON string (ApiSubscription[])
-- ARGV[2]: org_id
-- ARGV[3]: env
-- ARGV[4]: customer_id
-- ARGV[2]: serialized scheduled_subscriptions array JSON string (ApiSubscription[])
-- ARGV[3]: org_id
-- ARGV[4]: env
-- ARGV[5]: customer_id
local subscriptionsJson = ARGV[1]
local orgId = ARGV[2]
local env = ARGV[3]
local customerId = ARGV[4]
local scheduledSubscriptionsJson = ARGV[2]
local orgId = ARGV[3]
local env = ARGV[4]
local customerId = ARGV[5]
-- Build versioned cache key using shared utility
local cacheKey = buildCustomerCacheKey(orgId, env, customerId)
@@ -23,9 +25,11 @@ end
-- Decode the base customer and subscriptions
local baseCustomer = cjson.decode(baseJson)
local subscriptions = cjson.decode(subscriptionsJson)
local scheduledSubscriptions = cjson.decode(scheduledSubscriptionsJson)
-- Update the subscriptions field
-- Update the subscriptions fields
baseCustomer.subscriptions = subscriptions
baseCustomer.scheduled_subscriptions = scheduledSubscriptions
-- Store updated base customer as JSON and extend TTL
redis.call("SET", baseKey, cjson.encode(baseCustomer))

View File

@@ -998,6 +998,9 @@ local function processRequest(request, loadedCusFeatures, entityFeatureStates)
remainingAmount = result.remaining
else
-- Unlimited feature covers everything
-- Mark as "changed" so balance gets returned
customerChanged = true
changedCustomerFeatureIds[cusFeature.id] = true
remainingAmount = 0
end
else
@@ -1026,6 +1029,13 @@ local function processRequest(request, loadedCusFeatures, entityFeatureStates)
remainingAmount = result.remaining
else
-- Unlimited entity feature covers everything
-- Mark as "changed" so balance gets returned
changedEntityIds[entityId] = true
if not changedEntityFeatureIds[entityId] then
changedEntityFeatureIds[entityId] = {}
end
changedEntityFeatureIds[entityId][entityFeature.id] = true
remainingAmount = 0
end
end
@@ -1061,6 +1071,13 @@ local function processRequest(request, loadedCusFeatures, entityFeatureStates)
totalDeducted = totalDeducted + (amount - result.remaining)
remainingAmount = result.remaining
else
-- Unlimited entity feature covers everything
-- Mark as "changed" so balance gets returned
changedEntityIds[entId] = true
if not changedEntityFeatureIds[entId] then
changedEntityFeatureIds[entId] = {}
end
changedEntityFeatureIds[entId][entityFeature.id] = true
remainingAmount = 0
break
end
@@ -1118,6 +1135,9 @@ local function processRequest(request, loadedCusFeatures, entityFeatureStates)
end
else
-- Unlimited credit system covers everything
-- Mark as "changed" so balance gets returned
customerChanged = true
changedCustomerFeatureIds[otherCusFeature.id] = true
remainingAmount = 0
end
break

View File

@@ -41,6 +41,7 @@ for _, entityWrapper in ipairs(entities) do
created_at = entityData.created_at,
env = entityData.env,
subscriptions = entityData.subscriptions,
scheduled_subscriptions = entityData.scheduled_subscriptions,
legacyData = entityData.legacyData,
_balanceFeatureIds = balanceFeatureIds
}

View File

@@ -43,6 +43,7 @@ local baseEntity = {
created_at = entityData.created_at,
env = entityData.env,
subscriptions = entityData.subscriptions,
scheduled_subscriptions = entityData.scheduled_subscriptions,
legacyData = entityData.legacyData,
_balanceFeatureIds = balanceFeatureIds
}

View File

@@ -1,16 +1,18 @@
-- setEntityProducts.lua
-- Updates only the products array in the entity cache
-- ARGV[1]: serialized products array JSON string
-- ARGV[2]: org_id
-- ARGV[3]: env
-- ARGV[4]: customer_id
-- ARGV[5]: entity_id
-- Updates both subscriptions and scheduled_subscriptions arrays in the entity cache
-- ARGV[1]: serialized subscriptions array JSON string (ApiSubscription[])
-- ARGV[2]: serialized scheduled_subscriptions array JSON string (ApiSubscription[])
-- ARGV[3]: org_id
-- ARGV[4]: env
-- ARGV[5]: customer_id
-- ARGV[6]: entity_id
local productsJson = ARGV[1]
local orgId = ARGV[2]
local env = ARGV[3]
local customerId = ARGV[4]
local entityId = ARGV[5]
local subscriptionsJson = ARGV[1]
local scheduledSubscriptionsJson = ARGV[2]
local orgId = ARGV[3]
local env = ARGV[4]
local customerId = ARGV[5]
local entityId = ARGV[6]
-- Build versioned cache key using shared utility
local cacheKey = buildEntityCacheKey(orgId, env, customerId, entityId)
@@ -22,12 +24,14 @@ if not baseJson then
return "OK" -- Entity doesn't exist, return early
end
-- Decode the base entity and products
-- Decode the base entity and subscriptions
local baseEntity = cjson.decode(baseJson)
local products = cjson.decode(productsJson)
local subscriptions = cjson.decode(subscriptionsJson)
local scheduledSubscriptions = cjson.decode(scheduledSubscriptionsJson)
-- Update only the products array
baseEntity.products = products
-- Update the subscriptions fields
baseEntity.subscriptions = subscriptions
baseEntity.scheduled_subscriptions = scheduledSubscriptions
-- Store updated base entity as JSON and extend TTL
redis.call("SET", baseKey, cjson.encode(baseEntity))

View File

@@ -101,6 +101,13 @@ const deleteCustomerScript = readFileSync(
);
export const DELETE_CUSTOMER_SCRIPT = `${CACHE_KEY_UTILS}\n${deleteCustomerScript}`;
// Prepend cache key utils to BATCH_DELETE_CUSTOMERS_SCRIPT
const batchDeleteCustomersScript = readFileSync(
join(__dirname, "cusLuaScripts/batchDeleteCustomers.lua"),
"utf-8",
);
export const BATCH_DELETE_CUSTOMERS_SCRIPT = `${CACHE_KEY_UTILS}\n${batchDeleteCustomersScript}`;
// ============================================================================
// ENTITY SCRIPTS
// ============================================================================

View File

@@ -73,6 +73,27 @@ local function getCustomerObject(orgId, env, customerId, skipEntityMerge)
-- Merge subscriptions by plan ID and normalized status
baseCustomer.subscriptions = mergeSubscriptions(allSubscriptions)
-- Collect all scheduled_subscriptions: start with customer's scheduled_subscriptions, then add all entity scheduled_subscriptions
local allScheduledSubscriptions = {}
if baseCustomer.scheduled_subscriptions then
for _, subscription in ipairs(baseCustomer.scheduled_subscriptions) do
table.insert(allScheduledSubscriptions, subscription)
end
end
-- Add scheduled_subscriptions from each entity
for _, entityId in ipairs(entityIds) do
local entityBase = entityBaseData[entityId]
if entityBase and entityBase.scheduled_subscriptions then
for _, subscription in ipairs(entityBase.scheduled_subscriptions) do
table.insert(allScheduledSubscriptions, subscription)
end
end
end
-- Merge scheduled_subscriptions by plan ID and normalized status
baseCustomer.scheduled_subscriptions = mergeSubscriptions(allScheduledSubscriptions)
-- Merge invoices
-- Build final customer object
@@ -135,21 +156,26 @@ local function getEntityObject(orgId, env, customerId, entityId, skipCustomerMer
-- Get entity subscriptions (start with entity's own subscriptions)
local entitySubscriptions = baseEntity.subscriptions or {}
local entityScheduledSubscriptions = baseEntity.scheduled_subscriptions or {}
if not skipCustomerMerge then
-- Get customer subscriptions
-- Get customer subscriptions and scheduled_subscriptions
local customerSubscriptions = nil
local customerScheduledSubscriptions = nil
local customerBaseJson = redis.call("GET", customerCacheKey)
if customerBaseJson then
local customerBase = cjson.decode(customerBaseJson)
customerSubscriptions = customerBase.subscriptions
customerScheduledSubscriptions = customerBase.scheduled_subscriptions
end
-- Merge customer subscriptions into entity subscriptions (only add if not exists)
baseEntity.subscriptions = mergeCustomerSubscriptionsIntoEntity(entitySubscriptions, customerSubscriptions)
baseEntity.scheduled_subscriptions = mergeCustomerSubscriptionsIntoEntity(entityScheduledSubscriptions, customerScheduledSubscriptions)
else
-- No merging - just use entity's own subscriptions
baseEntity.subscriptions = entitySubscriptions
baseEntity.scheduled_subscriptions = entityScheduledSubscriptions
end
-- Build final entity object

View File

@@ -282,11 +282,13 @@ local function mergeFeatureBalances(targetBalance, sourceBalance)
-- Merge rollover balances
if sourceBalance.rollovers and #sourceBalance.rollovers > 0 then
-- Both have rollovers, merge them
for i, targetRollover in ipairs(targetBalance.rollovers) do
local sourceRollover = sourceBalance.rollovers[i]
if sourceRollover then
targetRollover.balance = toNum(targetRollover.balance) + toNum(sourceRollover.balance)
-- Both have rollovers, merge them
if targetBalance.rollovers and #targetBalance.rollovers > 0 then
for i, targetRollover in ipairs(targetBalance.rollovers) do
local sourceRollover = sourceBalance.rollovers[i]
if sourceRollover then
targetRollover.balance = toNum(targetRollover.balance) + toNum(sourceRollover.balance)
end
end
end
end

View File

@@ -1,485 +1,414 @@
import { config } from "dotenv";
config();
import assert from "node:assert";
import {
AppEnv,
CusProductStatus,
cusProductToPrices,
type Entity,
type FullCusProduct,
type FullCustomer,
type Organization,
} from "@autumn/shared";
import type Stripe from "stripe";
import { initDrizzle } from "@/db/initDrizzle.js";
import { createStripeCli } from "@/external/connect/createStripeCli.js";
import { getStripeSchedules } from "@/external/stripe/stripeSubUtils.js";
import { createSupabaseClient } from "@/external/supabaseUtils.js";
import { CusService } from "@/internal/customers/CusService.js";
import { OrgService } from "@/internal/orgs/OrgService.js";
import { isFreeProduct, isOneOff } from "@/internal/products/productUtils.js";
import { notNullish } from "@/utils/genUtils.js";
import {
getAllEntities,
getAllFullCustomers,
} from "@/utils/scriptUtils/getAll/getAllAutumnCustomers.js";
import {
getAllStripeSchedules,
getAllStripeSubscriptions,
} from "@/utils/scriptUtils/getAll/getAllStripeSubs.js";
import { EntityService } from "./internal/api/entities/EntityService.js";
import { getRelatedCusPrice } from "./internal/customers/cusProducts/cusEnts/cusEntUtils.js";
import { checkCusSubCorrect } from "./utils/checkUtils/checkCustomerCorrect.js";
const { db } = initDrizzle({ maxConnections: 5 });
let orgSlugs = process.env.ORG_SLUGS!.split(",");
const skipEmails = process.env.SKIP_EMAILS!.split(",");
const skipIds = [
"cus_2tXCCwC6iyiftgA6ndSo1Ubb2dx",
"DxG668K7uDd0Vahk54YWjvCGVgf2",
];
orgSlugs = ["nithiiyan_skhanthan_52620936"];
// let customerId = null;
const customerId = null;
const getSingleCustomer = async ({
stripeCli,
customerId,
orgId,
env,
}: {
stripeCli: Stripe;
customerId: string;
orgId: string;
env: AppEnv;
}) => {
const customers = [
await CusService.getFull({
db,
idOrInternalId: customerId,
orgId,
env,
}),
];
const stripeCusId = customers[0].processor?.id;
const stripeSubs = stripeCusId
? (
await stripeCli.subscriptions.list({
customer: stripeCusId,
expand: ["data.discounts.coupon"],
})
).data
: [];
// const stripeSubs = await getStripeSubs({
// stripeCli,
// subIds: customers[0].customer_products.flatMap(
// (cp) => cp.subscription_ids || []
// ),
// });
let scheduleIds = customers[0].customer_products.flatMap(
(cp) => cp.scheduled_ids || [],
);
scheduleIds = Array.from(new Set(scheduleIds));
const stripeSchedules = await getStripeSchedules({
stripeCli,
scheduleIds,
});
const entities = await EntityService.list({
db,
internalCustomerId: customers[0].internal_id,
});
return { customers, stripeSubs, stripeSchedules, entities };
};
const checkCustomerCorrect = async ({
fullCus,
subs,
schedules,
org,
entities,
}: {
fullCus: FullCustomer;
subs: Stripe.Subscription[];
schedules: Stripe.SubscriptionSchedule[];
org: Organization;
entities: Entity[];
}) => {
if (skipIds.includes(fullCus.internal_id!)) return;
if (skipEmails.some((skipEmail) => skipEmail === fullCus.email)) {
return;
}
fullCus.entities = entities.filter(
(entity) => entity.internal_customer_id === fullCus.internal_id,
);
// console.log(`Checking ${fullCus.email} (${fullCus.id})`);
const cusProducts = fullCus.customer_products;
await checkCusSubCorrect({
db,
fullCus,
subs,
schedules,
org,
env: AppEnv.Live,
});
for (const cusProduct of cusProducts) {
if (!cusProduct.subscription_ids) continue;
if (cusProduct.status === CusProductStatus.Scheduled) {
// Check if there's a main product elsewhere
const mainCusProd = cusProducts.find(
(cp: FullCusProduct) =>
cp.product.group === cusProduct.product.group &&
cp.id !== cusProduct.id &&
cp.status !== CusProductStatus.Scheduled &&
(cusProduct.internal_entity_id
? cusProduct.internal_entity_id === cp.internal_entity_id
: true),
);
assert(
mainCusProd,
`Found scheduled cus product with no main product (${cusProduct.product.name})`,
);
}
if (
!cusProduct.product.is_add_on &&
cusProduct.status !== CusProductStatus.Scheduled
) {
const group = cusProduct.product.group;
const otherCusProd = cusProducts.find(
(cp: FullCusProduct) =>
cp.product.group === group &&
cp.id !== cusProduct.id &&
!cp.product.is_add_on &&
cp.status !== CusProductStatus.Scheduled &&
cp.internal_entity_id === cusProduct.internal_entity_id,
);
assert(
!otherCusProd,
`found two cus products from the same group: ${otherCusProd?.product.name} and ${cusProduct.product.name}`,
);
}
const stripeSubs = subs.filter((sub: any) =>
cusProduct.subscription_ids!.some((id: string) => id === sub.id),
);
assert(
stripeSubs.length === cusProduct.subscription_ids!.length,
"number of stripe subs should be the same as number of subscription ids",
);
// let subItems = stripeSubs.flatMap((sub: any) => sub.items.data);
const prices = cusProductToPrices({ cusProduct });
if (
isOneOff(prices) ||
isFreeProduct(prices) ||
cusProduct.status === CusProductStatus.Scheduled
) {
continue;
}
for (const cusEnt of cusProduct.customer_entitlements) {
const cusPrice = getRelatedCusPrice(cusEnt, cusProduct.customer_prices);
if (cusEnt.usage_allowed && !cusPrice) {
assert.fail(
`Feature ${cusEnt.feature_id} has usage allowed but no related cus price`,
);
}
}
}
// Other checks to perform
};
const checkCustomerHandleError = async ({
fullCus,
subs,
org,
schedules,
entities,
}: {
fullCus: FullCustomer;
subs: Stripe.Subscription[];
org: Organization;
schedules: Stripe.SubscriptionSchedule[];
entities: Entity[];
}) => {
try {
await checkCustomerCorrect({
fullCus,
subs,
org,
schedules,
entities,
});
return undefined;
} catch (error: any) {
return {
id: fullCus.id,
name: fullCus.name,
email: fullCus.email,
error: error.message,
};
}
};
export const check = async () => {
const env = AppEnv.Live;
const sb = createSupabaseClient();
const today = new Date().toISOString().slice(0, 16);
for (const slug of orgSlugs) {
const org = await OrgService.getBySlug({
db,
slug,
});
if (!org) {
console.log(`Org ${slug} not found`);
continue;
}
const fileName = `errors/${today}-${org.slug}.json`;
const stripeCli = createStripeCli({
org,
env,
});
console.log("--------------------------------");
console.log(`Running error check for ${org.name}`);
let customers: FullCustomer[] = [];
let stripeSubs: Stripe.Subscription[] = [];
let stripeSchedules: Stripe.SubscriptionSchedule[] = [];
let entities: Entity[] = [];
if (customerId) {
const res = await getSingleCustomer({
stripeCli,
customerId,
orgId: org.id,
env,
});
customers = res.customers;
stripeSubs = res.stripeSubs;
entities = res.entities;
} else {
const [customersRes, stripeSubsRes, stripeSchedulesRes, entitiesRes] =
await Promise.all([
getAllFullCustomers({
db,
orgId: org.id,
env,
}),
getAllStripeSubscriptions({
stripeCli,
waitForSeconds: 1,
}),
getAllStripeSchedules({
stripeCli,
waitForSeconds: 1,
}),
getAllEntities({
db,
orgId: org.id,
env,
}),
]);
customers = customersRes;
stripeSubs = stripeSubsRes.subscriptions;
stripeSchedules = stripeSchedulesRes.schedules;
entities = entitiesRes;
}
const batchSize = 1;
const allErrors = [];
for (let i = 0; i < customers.length; i += batchSize) {
const batch = customers.slice(i, i + batchSize);
const batchCheck: any = [];
for (const customer of batch) {
batchCheck.push(
checkCustomerHandleError({
fullCus: customer,
subs: stripeSubs,
schedules: stripeSchedules,
org,
entities,
}),
);
}
let results = await Promise.all(batchCheck);
results = results.filter(notNullish);
allErrors.push(...results);
}
console.log(`Found ${allErrors.length} errors`);
if (allErrors.length > 0 && customers.length > 1) {
await sb.storage
.from("autumn")
.upload(fileName, JSON.stringify(allErrors, null, 2));
if (allErrors.length > 0) {
const slackBody = {
text: `Error check for ${org.name}`,
blocks: [
{
type: "section",
text: {
type: "mrkdwn",
text: `*Error check for ${org.name}*: found ${allErrors.length} errors\nSee results at ${process.env.SUPABASE_URL}/storage/v1/object/public/autumn/${fileName}`,
},
},
],
};
await fetch(process.env.SLACK_WEBHOOK_URL!, {
method: "POST",
body: JSON.stringify(slackBody),
});
}
} else {
console.log(allErrors);
}
}
console.log(
`COMPLETED ERROR CHECK FOR ${new Date().toISOString().slice(0, 16)}`,
);
if (process.env.NODE_ENV === "production") {
const slackBody = {
text: `Completed error check for ${new Date().toISOString().slice(0, 16)}`,
blocks: [
{
type: "section",
text: {
type: "mrkdwn",
text: `Error check completed for ${new Date().toISOString().slice(0, 16)}`,
},
},
],
};
await fetch(process.env.SLACK_WEBHOOK_URL!, {
method: "POST",
body: JSON.stringify(slackBody),
});
}
};
check()
.catch((error) => {
console.error(error);
process.exit(1);
})
.finally(() => {
process.exit(0);
});
// let missingUsageCount = 0;
// for (const price of prices) {
// const subItem = findStripeItemForPrice({
// stripeItems: subItems,
// price,
// stripeProdId: cusProduct.product.processor?.id,
// });
// let billingType = getBillingType(price.config);
// if (
// billingType == BillingType.UsageInAdvance &&
// price.config.interval == BillingInterval.OneOff
// ) {
// missingUsageCount++;
// continue;
// }
// if (
// billingType == BillingType.UsageInAdvance &&
// price.config.interval != BillingInterval.OneOff
// ) {
// const featureId = (price.config as any).feature_id;
// const options = cusProduct.options.find(
// (o) => o.feature_id == featureId
// );
// assert(
// notNullish(options),
// `options should exist for prepaid price (featureId: ${featureId})`
// );
// let expectedQuantity = options?.upcoming_quantity || options?.quantity;
// // console.log("Sub item: ", subItem);
// assert(
// subItem?.quantity == expectedQuantity,
// `sub item quantity for prepaid price (featureId: ${featureId}) should be ${expectedQuantity}`
// );
// continue;
// }
// if (isV4Usage({ price, cusProduct })) {
// if (nullish(subItem)) {
// missingUsageCount++;
// } else {
// const priceName =
// (price.config as any).feature_id || price.config.interval;
// assert(
// nullish(subItem) ||
// (subItem?.quantity === 0 &&
// isLicenseItem({
// stripeItem: subItem as Stripe.SubscriptionItem,
// })),
// `(${cusProduct.product.name}) sub item for price: ${priceName} should exist`
// );
// }
// continue;
// } else {
// let priceName =
// (price.config as any).feature_id || price.config.interval;
// // console.log("Stripe price ID:", price.config.stripe_price_id);
// // console.log("Sub items:", subItems);
// assert(
// subItem,
// `(${cusProduct.product.name}) sub item for price: ${priceName} should exist`
// );
// }
// }
// assert(
// prices.length - missingUsageCount === subItems.length,
// `(${cusProduct.product.name}) number of sub items equivalent to number of prices`
// );
// import { config } from "dotenv";
// config();
// import assert from "node:assert";
// import {
// AppEnv,
// CusProductStatus,
// cusProductToPrices,
// type Entity,
// type FullCusProduct,
// type FullCustomer,
// type Organization,
// } from "@autumn/shared";
// import type Stripe from "stripe";
// import { initDrizzle } from "@/db/initDrizzle.js";
// import { createStripeCli } from "@/external/connect/createStripeCli.js";
// import { getStripeSchedules } from "@/external/stripe/stripeSubUtils.js";
// import { createSupabaseClient } from "@/external/supabaseUtils.js";
// import { CusService } from "@/internal/customers/CusService.js";
// import { OrgService } from "@/internal/orgs/OrgService.js";
// import { isFreeProduct, isOneOff } from "@/internal/products/productUtils.js";
// import { notNullish } from "@/utils/genUtils.js";
// import {
// getAllEntities,
// getAllFullCustomers,
// } from "@/utils/scriptUtils/getAll/getAllAutumnCustomers.js";
// import {
// getAllStripeSchedules,
// getAllStripeSubscriptions,
// } from "@/utils/scriptUtils/getAll/getAllStripeSubs.js";
// import { EntityService } from "./internal/api/entities/EntityService.js";
// import { getRelatedCusPrice } from "./internal/customers/cusProducts/cusEnts/cusEntUtils.js";
// import { checkCusSubCorrect } from "./utils/checkUtils/checkCustomerCorrect.js";
// const { db } = initDrizzle({ maxConnections: 5 });
// let orgSlugs = process.env.ORG_SLUGS!.split(",");
// const skipEmails = process.env.SKIP_EMAILS!.split(",");
// const skipIds = [
// "cus_2tXCCwC6iyiftgA6ndSo1Ubb2dx",
// "DxG668K7uDd0Vahk54YWjvCGVgf2",
// ];
// orgSlugs = ["athenahq"];
// // let customerId = null;
// const customerId = null;
// const getSingleCustomer = async ({
// stripeCli,
// customerId,
// orgId,
// env,
// }: {
// stripeCli: Stripe;
// customerId: string;
// orgId: string;
// env: AppEnv;
// }) => {
// const customers = [
// await CusService.getFull({
// db,
// idOrInternalId: customerId,
// orgId,
// env,
// }),
// ];
// const stripeCusId = customers[0].processor?.id;
// const stripeSubs = stripeCusId
// ? (
// await stripeCli.subscriptions.list({
// customer: stripeCusId,
// expand: ["data.discounts.coupon"],
// })
// ).data
// : [];
// // const stripeSubs = await getStripeSubs({
// // stripeCli,
// // subIds: customers[0].customer_products.flatMap(
// // (cp) => cp.subscription_ids || []
// // ),
// // });
// let scheduleIds = customers[0].customer_products.flatMap(
// (cp) => cp.scheduled_ids || [],
// );
// scheduleIds = Array.from(new Set(scheduleIds));
// const stripeSchedules = await getStripeSchedules({
// stripeCli,
// scheduleIds,
// });
// const entities = await EntityService.list({
// db,
// internalCustomerId: customers[0].internal_id,
// });
// return { customers, stripeSubs, stripeSchedules, entities };
// };
// const checkCustomerCorrect = async ({
// fullCus,
// subs,
// schedules,
// org,
// entities,
// }: {
// fullCus: FullCustomer;
// subs: Stripe.Subscription[];
// schedules: Stripe.SubscriptionSchedule[];
// org: Organization;
// entities: Entity[];
// }) => {
// if (skipIds.includes(fullCus.internal_id!)) return;
// if (skipEmails.some((skipEmail) => skipEmail === fullCus.email)) {
// return;
// }
// fullCus.entities = entities.filter(
// (entity) => entity.internal_customer_id === fullCus.internal_id,
// );
// // console.log(`Checking ${fullCus.email} (${fullCus.id})`);
// const cusProducts = fullCus.customer_products;
// await checkCusSubCorrect({
// db,
// fullCus,
// subs,
// schedules,
// org,
// env: AppEnv.Live,
// });
// for (const cusProduct of cusProducts) {
// if (!cusProduct.subscription_ids) continue;
// if (cusProduct.status === CusProductStatus.Scheduled) {
// // Check if there's a main product elsewhere
// const mainCusProd = cusProducts.find(
// (cp: FullCusProduct) =>
// cp.product.group === cusProduct.product.group &&
// cp.id !== cusProduct.id &&
// cp.status !== CusProductStatus.Scheduled &&
// (cusProduct.internal_entity_id
// ? cusProduct.internal_entity_id === cp.internal_entity_id
// : true),
// );
// assert(
// mainCusProd,
// `Found scheduled cus product with no main product (${cusProduct.product.name})`,
// );
// }
// if (
// !cusProduct.product.is_add_on &&
// cusProduct.status !== CusProductStatus.Scheduled
// ) {
// const group = cusProduct.product.group;
// const otherCusProd = cusProducts.find(
// (cp: FullCusProduct) =>
// cp.product.group === group &&
// cp.id !== cusProduct.id &&
// !cp.product.is_add_on &&
// cp.status !== CusProductStatus.Scheduled &&
// cp.internal_entity_id === cusProduct.internal_entity_id,
// );
// assert(
// !otherCusProd,
// `found two cus products from the same group: ${otherCusProd?.product.name} and ${cusProduct.product.name}`,
// );
// }
// const stripeSubs = subs.filter((sub: any) =>
// cusProduct.subscription_ids!.some((id: string) => id === sub.id),
// );
// assert(
// stripeSubs.length === cusProduct.subscription_ids!.length,
// "number of stripe subs should be the same as number of subscription ids",
// );
// // let subItems = stripeSubs.flatMap((sub: any) => sub.items.data);
// const prices = cusProductToPrices({ cusProduct });
// if (
// isOneOff(prices) ||
// isFreeProduct(prices) ||
// cusProduct.status === CusProductStatus.Scheduled
// ) {
// continue;
// }
// for (const cusEnt of cusProduct.customer_entitlements) {
// const cusPrice = getRelatedCusPrice(cusEnt, cusProduct.customer_prices);
// if (cusEnt.usage_allowed && !cusPrice) {
// assert.fail(
// `Feature ${cusEnt.feature_id} has usage allowed but no related cus price`,
// );
// }
// }
// }
// // Other checks to perform
// };
// const checkCustomerHandleError = async ({
// fullCus,
// subs,
// org,
// schedules,
// entities,
// }: {
// fullCus: FullCustomer;
// subs: Stripe.Subscription[];
// org: Organization;
// schedules: Stripe.SubscriptionSchedule[];
// entities: Entity[];
// }) => {
// try {
// await checkCustomerCorrect({
// fullCus,
// subs,
// org,
// schedules,
// entities,
// });
// return undefined;
// } catch (error: any) {
// return {
// id: fullCus.id,
// name: fullCus.name,
// email: fullCus.email,
// error: error.message,
// };
// }
// };
// export const check = async () => {
// const env = AppEnv.Live;
// const sb = createSupabaseClient();
// const today = new Date().toISOString().slice(0, 16);
// for (const slug of orgSlugs) {
// const org = await OrgService.getBySlug({
// db,
// slug,
// });
// if (!org) {
// console.log(`Org ${slug} not found`);
// continue;
// }
// const fileName = `errors/${today}-${org.slug}.json`;
// const stripeCli = createStripeCli({
// org,
// env,
// });
// console.log("--------------------------------");
// console.log(`Running error check for ${org.name}`);
// let customers: FullCustomer[] = [];
// let stripeSubs: Stripe.Subscription[] = [];
// let stripeSchedules: Stripe.SubscriptionSchedule[] = [];
// let entities: Entity[] = [];
// if (customerId) {
// const res = await getSingleCustomer({
// stripeCli,
// customerId,
// orgId: org.id,
// env,
// });
// customers = res.customers;
// stripeSubs = res.stripeSubs;
// entities = res.entities;
// } else {
// const [customersRes, stripeSubsRes, stripeSchedulesRes, entitiesRes] =
// await Promise.all([
// getAllFullCustomers({
// db,
// orgId: org.id,
// env,
// }),
// getAllStripeSubscriptions({
// stripeCli,
// waitForSeconds: 1,
// }),
// getAllStripeSchedules({
// stripeCli,
// waitForSeconds: 1,
// }),
// getAllEntities({
// db,
// orgId: org.id,
// env,
// }),
// ]);
// customers = customersRes;
// stripeSubs = stripeSubsRes.subscriptions;
// stripeSchedules = stripeSchedulesRes.schedules;
// entities = entitiesRes;
// }
// const batchSize = 1;
// const allErrors = [];
// for (let i = 0; i < customers.length; i += batchSize) {
// const batch = customers.slice(i, i + batchSize);
// const batchCheck: any = [];
// for (const customer of batch) {
// batchCheck.push(
// checkCustomerHandleError({
// fullCus: customer,
// subs: stripeSubs,
// schedules: stripeSchedules,
// org,
// entities,
// }),
// );
// }
// let results = await Promise.all(batchCheck);
// results = results.filter(notNullish);
// allErrors.push(...results);
// }
// console.log(`Found ${allErrors.length} errors`);
// if (allErrors.length > 0 && customers.length > 1) {
// await sb.storage
// .from("autumn")
// .upload(fileName, JSON.stringify(allErrors, null, 2));
// if (allErrors.length > 0) {
// const slackBody = {
// text: `Error check for ${org.name}`,
// blocks: [
// {
// type: "section",
// text: {
// type: "mrkdwn",
// text: `*Error check for ${org.name}*: found ${allErrors.length} errors\nSee results at ${process.env.SUPABASE_URL}/storage/v1/object/public/autumn/${fileName}`,
// },
// },
// ],
// };
// await fetch(process.env.SLACK_WEBHOOK_URL!, {
// method: "POST",
// body: JSON.stringify(slackBody),
// });
// }
// } else {
// console.log(allErrors);
// }
// }
// console.log(
// `COMPLETED ERROR CHECK FOR ${new Date().toISOString().slice(0, 16)}`,
// );
// if (process.env.NODE_ENV === "production") {
// const slackBody = {
// text: `Completed error check for ${new Date().toISOString().slice(0, 16)}`,
// blocks: [
// {
// type: "section",
// text: {
// type: "mrkdwn",
// text: `Error check completed for ${new Date().toISOString().slice(0, 16)}`,
// },
// },
// ],
// };
// await fetch(process.env.SLACK_WEBHOOK_URL!, {
// method: "POST",
// body: JSON.stringify(slackBody),
// });
// }
// };
// check()
// .catch((error) => {
// console.error(error);
// process.exit(1);
// })
// .finally(() => {
// process.exit(0);
// });
import { initInfisical } from "./external/infisical/initInfisical.js";
await initInfisical();
await import("./scan/runScan.js");

View File

@@ -1,3 +1,4 @@
import "../sentry.ts";
import type { CustomerEntitlement, ResetCusEnt } from "@autumn/shared";
import { UTCDate } from "@date-fns/utc";
import { CronJob } from "cron";
@@ -5,17 +6,15 @@ import { format } from "date-fns";
import { initDrizzle } from "../db/initDrizzle.js";
import { CusEntService } from "../internal/customers/cusProducts/cusEnts/CusEntitlementService.js";
import { notNullish } from "../utils/genUtils.js";
import { resetCustomerEntitlement } from "./cronUtils.js";
import {
clearCusEntsFromCache,
resetCustomerEntitlement,
} from "./cronUtils.js";
import { runProductCron } from "./productCron/runProductCron.js";
const { db, client } = initDrizzle();
export const cronTask = async () => {
console.log(
"\n----------------------------------\nRUNNING RESET CRON:",
format(new UTCDate(), "yyyy-MM-dd HH:mm:ss"),
);
try {
const cusEnts: ResetCusEnt[] = await CusEntService.getActiveResetPassed({
db,
@@ -26,11 +25,13 @@ export const cronTask = async () => {
for (let i = 0; i < cusEnts.length; i += batchSize) {
const batch = cusEnts.slice(i, i + batchSize);
const batchResets = [];
const updatedCusEnts: ResetCusEnt[] = [];
for (const cusEnt of batch) {
batchResets.push(
resetCustomerEntitlement({
db,
cusEnt: cusEnt,
updatedCusEnts,
}),
);
}
@@ -43,6 +44,8 @@ export const cronTask = async () => {
data: toUpsert as CustomerEntitlement[],
});
console.log(`Upserted ${toUpsert.length} short entitlements`);
await clearCusEntsFromCache({ cusEnts: updatedCusEnts });
}
console.log(
@@ -59,6 +62,10 @@ export const cronTask = async () => {
};
const main = async () => {
if (process.env.DISABLE_CRON === "true") {
console.log(`Cron disabled!`);
return;
}
await Promise.all([cronTask(), runProductCron()]);
};

View File

@@ -5,6 +5,7 @@ import {
type FullCusEntWithProduct,
type FullEntitlement,
getStartingBalance,
notNullish,
type Organization,
type ResetCusEnt,
} from "@autumn/shared";
@@ -19,12 +20,11 @@ import { getRelatedCusPrice } from "@/internal/customers/cusProducts/cusEnts/cus
import { getRolloverUpdates } from "@/internal/customers/cusProducts/cusEnts/cusRollovers/rolloverUtils.js";
import { getResetBalancesUpdate } from "@/internal/customers/cusProducts/cusEnts/groupByUtils.js";
import { CusPriceService } from "@/internal/customers/cusProducts/cusPrices/CusPriceService.js";
import { OrgService } from "@/internal/orgs/OrgService.js";
import { getEntOptions } from "@/internal/products/prices/priceUtils.js";
import { getNextResetAt } from "@/utils/timeUtils.js";
import type { DrizzleCli } from "../db/initDrizzle.js";
import { RolloverService } from "../internal/customers/cusProducts/cusEnts/cusRollovers/RolloverService.js";
import { deleteCachedApiCustomer } from "../internal/customers/cusUtils/apiCusCacheUtils/deleteCachedApiCustomer.js";
import { batchDeleteCachedCustomers } from "../internal/customers/cusUtils/apiCusCacheUtils/batchDeleteCachedCustomers.js";
const checkSubAnchor = async ({
db,
@@ -94,9 +94,11 @@ const checkSubAnchor = async ({
const handleShortDurationCusEnt = async ({
db,
cusEnt,
updatedCusEnts,
}: {
db: DrizzleCli;
cusEnt: ResetCusEnt;
updatedCusEnts: ResetCusEnt[];
}) => {
const ent = cusEnt.entitlement as FullEntitlement;
@@ -134,16 +136,18 @@ const handleShortDurationCusEnt = async ({
`Reseting short cus ent (${cusEnt.feature_id}) [${ent.interval}], customer: ${cusEnt.customer_id}, org: ${cusEnt.customer.org_id}`,
);
const org = await OrgService.get({
db,
orgId: cusEnt.customer.org_id,
});
// const org = await OrgService.get({
// db,
// orgId: cusEnt.customer.org_id,
// });
await deleteCachedApiCustomer({
customerId: cusEnt.customer.id!,
orgId: org.id,
env: cusEnt.customer.env,
});
// await deleteCachedApiCustomer({
// customerId: cusEnt.customer.id!,
// orgId: org.id,
// env: cusEnt.customer.env,
// });
updatedCusEnts.push(newCusEnt);
return newCusEnt;
};
@@ -153,9 +157,11 @@ const shortDurations = [EntInterval.Minute, EntInterval.Hour, EntInterval.Day];
export const resetCustomerEntitlement = async ({
db,
cusEnt,
updatedCusEnts,
}: {
db: DrizzleCli;
cusEnt: ResetCusEnt;
updatedCusEnts: ResetCusEnt[];
}) => {
try {
const ent = cusEnt.entitlement as FullEntitlement;
@@ -167,6 +173,7 @@ export const resetCustomerEntitlement = async ({
return await handleShortDurationCusEnt({
db,
cusEnt,
updatedCusEnts,
});
}
@@ -282,6 +289,8 @@ export const resetCustomerEntitlement = async ({
});
}
updatedCusEnts.push(cusEnt);
console.log(
`Reset ${cusEnt.id} | customer: ${chalk.yellow(
cusEnt.customer_id,
@@ -293,20 +302,27 @@ export const resetCustomerEntitlement = async ({
format(new UTCDate(nextResetAt), "dd MMM yyyy HH:mm:ss"),
)}`,
);
const org = await OrgService.get({
db,
orgId: cusEnt.customer.org_id,
});
await deleteCachedApiCustomer({
customerId: cusEnt.customer.id!,
orgId: org.id,
env: cusEnt.customer.env,
});
} catch (error: any) {
console.log(
`Failed to reset ${cusEnt.id} | ${cusEnt.customer_id} | ${cusEnt.feature_id}, error: ${error}`,
);
}
};
export const clearCusEntsFromCache = async ({
cusEnts,
}: {
cusEnts: ResetCusEnt[];
}) => {
const customersToDelete = cusEnts
.filter((ce) => notNullish(ce.customer.id))
.map((cusEnt) => ({
orgId: cusEnt.customer.org_id,
env: cusEnt.customer.env,
customerId: cusEnt.customer.id!,
}));
if (customersToDelete.length === 0) return;
await batchDeleteCachedCustomers({ customers: customersToDelete });
};

View File

@@ -4,10 +4,11 @@ import {
customerPrices,
customerProducts,
customers,
notNullish,
} from "@autumn/shared";
import { and, eq, inArray, isNotNull, lt, notExists, sql } from "drizzle-orm";
import { db } from "@/db/initDrizzle.js";
import { deleteCachedApiCustomer } from "../../internal/customers/cusUtils/apiCusCacheUtils/deleteCachedApiCustomer";
import { batchDeleteCachedCustomers } from "../../internal/customers/cusUtils/apiCusCacheUtils/batchDeleteCachedCustomers";
export const runProductCron = async () => {
console.log("Running product cron");
@@ -64,17 +65,26 @@ export const runProductCron = async () => {
`Expired batch of ${i + batch.length}/${results.length} customer products`,
);
const clearCachePromises = [];
for (const result of batch) {
clearCachePromises.push(
deleteCachedApiCustomer({
customerId: result.customers.id ?? "",
orgId: result.customers.org_id,
env: result.customers.env,
}),
);
}
await Promise.all(clearCachePromises);
await batchDeleteCachedCustomers({
customers: batch
.filter((r) => notNullish(r.customers.id))
.map((r) => ({
orgId: r.customers.org_id,
env: r.customers.env,
customerId: r.customers.id!,
})),
});
// const clearCachePromises = [];
// for (const result of batch) {
// clearCachePromises.push(
// deleteCachedApiCustomer({
// customerId: result.customers.id ?? "",
// orgId: result.customers.org_id,
// env: result.customers.env,
// }),
// );
// }
// await Promise.all(clearCachePromises);
}
return results;

View File

@@ -5,7 +5,7 @@ dotenv.config();
import {
type ApiBaseEntity,
type AttachBody,
type AttachBodyV0,
type BalancesUpdateParams,
type CheckQuery,
type CreateCustomerParams,
@@ -246,7 +246,7 @@ export class AutumnInt {
return data;
}
async attach(params: AttachBody) {
async attach(params: AttachBodyV0) {
// const data = await this.post(`/attach`, {
// customer_id: customerId,
// product_id: productId,
@@ -638,7 +638,7 @@ export class AutumnInt {
return data;
};
attachPreview = async (params: AttachBody) => {
attachPreview = async (params: AttachBodyV0) => {
const data = await this.post(`/attach/preview`, params);
return data;
};

View File

@@ -4,7 +4,7 @@ import dotenv from "dotenv";
dotenv.config();
import {
type AttachBody,
type AttachBodyV0,
type CreateEntityParams,
type CreateRewardProgram,
CusExpand,
@@ -197,7 +197,7 @@ export class AutumnCliV2 {
});
}
async attach(params: AttachBody) {
async attach(params: AttachBodyV0) {
return await this.post(`/attach`, params);
}
@@ -480,7 +480,7 @@ export class AutumnCliV2 {
return await this.post(`/check`, params);
};
attachPreview = async (params: AttachBody) => {
attachPreview = async (params: AttachBodyV0) => {
return await this.post(`/attach/preview`, params);
};

View File

@@ -1,4 +1,17 @@
import { Redis } from "ioredis";
import {
BATCH_DELETE_CUSTOMERS_SCRIPT,
DELETE_CUSTOMER_SCRIPT,
GET_CUSTOMER_SCRIPT,
GET_ENTITY_SCRIPT,
getBatchDeductionScript,
SET_CUSTOMER_DETAILS_SCRIPT,
SET_CUSTOMER_SCRIPT,
SET_ENTITIES_BATCH_SCRIPT,
SET_ENTITY_PRODUCTS_SCRIPT,
SET_INVOICES_SCRIPT,
SET_SUBSCRIPTIONS_SCRIPT,
} from "../../_luaScripts/luaScripts.js";
import { loadCaCert } from "./loadCaCert.js";
if (!process.env.CACHE_URL) {
@@ -23,8 +36,139 @@ const caText = await loadCaCert({
const redis = new Redis(regionalCacheUrl || process.env.CACHE_URL, {
tls: caText ? { ca: caText } : undefined,
family: 4,
keepAlive: 10000,
});
// Load Lua scripts using the builder functions that include dependencies
const batchDeductionScript = getBatchDeductionScript();
// Define commands
redis.defineCommand("batchDeduction", {
numberOfKeys: 0,
lua: batchDeductionScript,
});
redis.defineCommand("getCustomer", {
numberOfKeys: 0,
lua: GET_CUSTOMER_SCRIPT,
});
redis.defineCommand("setCustomer", {
numberOfKeys: 0,
lua: SET_CUSTOMER_SCRIPT,
});
redis.defineCommand("setEntitiesBatch", {
numberOfKeys: 0,
lua: SET_ENTITIES_BATCH_SCRIPT,
});
redis.defineCommand("getEntity", {
numberOfKeys: 0,
lua: GET_ENTITY_SCRIPT,
});
redis.defineCommand("setSubscriptions", {
numberOfKeys: 0,
lua: SET_SUBSCRIPTIONS_SCRIPT,
});
redis.defineCommand("setEntityProducts", {
numberOfKeys: 0,
lua: SET_ENTITY_PRODUCTS_SCRIPT,
});
redis.defineCommand("setInvoices", {
numberOfKeys: 0,
lua: SET_INVOICES_SCRIPT,
});
redis.defineCommand("setCustomerDetails", {
numberOfKeys: 0,
lua: SET_CUSTOMER_DETAILS_SCRIPT,
});
redis.defineCommand("deleteCustomer", {
numberOfKeys: 0,
lua: DELETE_CUSTOMER_SCRIPT,
});
redis.defineCommand("batchDeleteCustomers", {
numberOfKeys: 0,
lua: BATCH_DELETE_CUSTOMERS_SCRIPT,
});
// Add type definitions
declare module "ioredis" {
interface RedisCommander {
batchDeduction(
requestsJson: string,
orgId: string,
env: string,
customerId: string,
adjustGrantedBalance?: string,
): Promise<string>;
getCustomer(
orgId: string,
env: string,
customerId: string,
skipEntityMerge: string,
): Promise<string>;
setCustomer(
customerData: string,
orgId: string,
env: string,
customerId: string,
): Promise<string>;
setEntitiesBatch(
entityBatch: string,
orgId: string,
env: string,
): Promise<string>;
getEntity(
orgId: string,
env: string,
customerId: string,
entityId: string,
skipCustomerMerge: string,
): Promise<string>;
setSubscriptions(
subscriptionsJson: string,
scheduledSubscriptionsJson: string,
orgId: string,
env: string,
customerId: string,
): Promise<string>;
setEntityProducts(
subscriptionsJson: string,
scheduledSubscriptionsJson: string,
orgId: string,
env: string,
customerId: string,
entityId: string,
): Promise<string>;
setInvoices(
invoicesJson: string,
orgId: string,
env: string,
customerId: string,
): Promise<string>;
setCustomerDetails(
updatesJson: string,
orgId: string,
env: string,
customerId: string,
): Promise<string>;
deleteCustomer(
orgId: string,
env: string,
customerId: string,
): Promise<number>;
batchDeleteCustomers(customersJson: string): Promise<number>;
}
}
// biome-ignore lint/correctness/noUnusedFunctionParameters: Might uncomment this back in in the future
redis.on("error", (error) => {
// logger.error(`redis (cache) error: ${error.message}`);

View File

@@ -1,5 +1,4 @@
import { ErrCode } from "@autumn/shared";
import RecaseError from "@/utils/errorUtils.js";
import { ErrCode, RecaseError } from "@autumn/shared";
import { redis } from "./initRedis.js";
export const handleAttachRaceCondition = async ({

View File

@@ -5,10 +5,14 @@ export const setSentryTags = ({
ctx,
customerId,
messageId,
path,
method,
}: {
ctx: AutumnContext;
customerId?: string;
messageId?: string;
path?: string;
method?: string;
}) => {
Sentry.setTags({
org_id: ctx.org.id,
@@ -17,5 +21,36 @@ export const setSentryTags = ({
request_id: ctx.id,
customer_id: customerId,
message_id: messageId,
path: path,
method: method,
});
};
export const getSentryTags = ({
ctx,
customerId,
messageId,
path,
method,
}: {
ctx: AutumnContext;
customerId?: string;
messageId?: string;
path?: string;
method?: string;
}) => {
if (!ctx) return;
return {
org_id: ctx.org?.id,
org_slug: ctx.org?.slug,
env: ctx.env || "unknown",
auth_type: ctx.authType,
request_id: ctx.id || "",
customer_id: customerId,
message_id: messageId,
path: path,
method: method,
email: ctx.user?.email,
};
};

View File

@@ -19,7 +19,10 @@ import { findPrepaidPrice } from "@/internal/products/prices/priceUtils/findPric
import { formatAmount } from "@/utils/formatUtils.js";
import { sortProductsByPrice } from "../../../internal/products/productUtils/sortProductUtils.js";
import { isOneOff } from "../../../internal/products/productUtils.js";
import {
isFreeProduct,
isOneOff,
} from "../../../internal/products/productUtils.js";
import type { VercelBillingPlan } from "../misc/vercelTypes.js";
/**
@@ -157,12 +160,13 @@ export const listVercelPlansForOrg = async ({
// 1. Get rid of products that have usage prices
// 2. Get rid of products that are archived
// 3. Get rid of products that are one off, only if they are not free
const filteredProducts = products
.filter((p) => !p.prices.some((price) => isUsagePrice({ price })))
.filter(
(p) =>
!p.is_add_on &&
!isOneOff(p.prices) &&
(!isOneOff(p.prices) || isFreeProduct(p.prices)) &&
!p.archived &&
(p.entitlements.length > 0 || p.is_default),
);

View File

@@ -163,7 +163,7 @@ export const handleUpsertInstallation = createRoute({
}),
orgCurrency: ctx.org.default_currency ?? "usd",
})
: null,
: undefined,
};
return c.json(installation, 200);

View File

@@ -1,7 +1,14 @@
import { AppEnv, CusExpand, RecaseError } from "@autumn/shared";
import {
AppEnv,
CusExpand,
type FullProduct,
RecaseError,
} from "@autumn/shared";
import { ErrCode } from "@shared/enums/ErrCode.js";
import { DrizzleError } from "drizzle-orm";
import { StatusCodes } from "http-status-codes";
import { z } from "zod/v4";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import { createStripeCli } from "@/external/connect/createStripeCli.js";
import { sendCustomSvixEvent } from "@/external/svix/svixHelpers.js";
import { createVercelSubscription } from "@/external/vercel/misc/vercelSubscriptions.js";
@@ -73,37 +80,50 @@ export const handleCreateResource = createRoute({
// 2. Create resource in database (enforces 1-resource limit)
const resourceId = generateId("vre");
await VercelResourceService.create({
db,
resource: {
id: resourceId,
org_id: orgId,
env: env as AppEnv,
installation_id: integrationConfigurationId,
name,
status: "pending",
metadata: metadata ?? {},
},
});
// 3. Create subscription (installation-level billing)
const { product } = await createVercelSubscription({
db,
org,
env: env as AppEnv,
customer,
stripeCustomer,
stripeCli,
integrationConfigurationId,
billingPlanId,
features,
logger,
c,
metadata,
resourceId,
});
try {
const product = await db.transaction(async (tx) => {
await VercelResourceService.create({
db: tx as unknown as DrizzleCli,
resource: {
id: resourceId,
org_id: orgId,
env: env as AppEnv,
installation_id: integrationConfigurationId,
name,
status: "pending",
metadata: metadata ?? {},
},
});
let createdProduct: FullProduct;
try {
// 3. Create subscription (installation-level billing)
const { product } = await createVercelSubscription({
db: tx as unknown as DrizzleCli,
org,
env: env as AppEnv,
customer,
stripeCustomer,
stripeCli,
integrationConfigurationId,
billingPlanId,
features,
logger,
c,
metadata,
resourceId,
});
createdProduct = product;
} catch (error) {
tx.rollback();
throw error;
}
return createdProduct;
});
await sendCustomSvixEvent({
appId:
org.processor_configs?.vercel?.svix?.[
@@ -121,27 +141,46 @@ export const handleCreateResource = createRoute({
access_token: customer.processors?.vercel?.access_token ?? "",
} satisfies VercelResourceCreatedEvent,
});
} catch (_error) {}
// 4. Return resource response
return c.json({
id: resourceId,
productId,
name,
metadata,
status: "pending", // Will become "ready" after marketplace.invoice.paid confirms payment
billingPlan: {
...productToBillingPlan({
product,
orgCurrency: org?.default_currency ?? "usd",
}),
scope: "installation", // Always installation-level
},
secrets: [],
notification: {
level: "info",
title: "Resource provisioning",
message: `Setting up ${name}...`,
},
});
// 4. Return resource response
return c.json({
id: resourceId,
productId,
name,
metadata,
status: "pending", // Will become "ready" after marketplace.invoice.paid confirms payment
billingPlan: {
...productToBillingPlan({
product,
orgCurrency: org?.default_currency ?? "usd",
}),
scope: "installation", // Always installation-level
},
secrets: [],
notification: {
level: "info",
title: "Resource provisioning",
message: `Setting up ${name}...`,
},
});
} catch (error) {
return c.json(
{
error: {
code: "conflict",
message:
error instanceof DrizzleError
? error.message.includes("Rollback")
? "An error occurred while creating the resource's subscription"
: error.message
: error instanceof RecaseError
? error.message
: "An error occurred while creating the resource",
user: null,
},
},
StatusCodes.CONFLICT,
);
}
},
});

View File

@@ -15,7 +15,10 @@ import { getCusPaymentMethod } from "@/external/stripe/stripeCusUtils.js";
import { getStripeSubItems2 } from "@/external/stripe/stripeSubUtils/getStripeSubItems.js";
import type { HonoEnv } from "@/honoUtils/HonoEnv.js";
import { createStripeSub2 } from "@/internal/customers/attach/attachFunctions/addProductFlow/createStripeSub2.js";
import { handleFreeProduct } from "@/internal/customers/attach/attachFunctions/addProductFlow/handleAddProduct.js";
import { CusService } from "@/internal/customers/CusService.js";
import { ProductService } from "@/internal/products/ProductService.js";
import { isFreeProduct } from "@/internal/products/productUtils.js";
import {
getVercelAttachBody,
parseVercelPrepaidQuantities,
@@ -64,7 +67,7 @@ export const createVercelSubscription = async ({
c: Context<HonoEnv>;
metadata?: Record<string, any>;
resourceId?: string;
}): Promise<{ subscription: Stripe.Subscription; product: FullProduct }> => {
}): Promise<{ product: FullProduct }> => {
// 1. Check for existing non-incomplete subscription (only allow one per installation)
const existingSubscription = stripeCustomer.subscriptions?.data.find(
(s) =>
@@ -101,6 +104,13 @@ export const createVercelSubscription = async ({
});
}
const refreshedCustomer = await CusService.getFull({
db,
idOrInternalId: customer.internal_id,
orgId: org.id,
env,
});
// 3. Get custom payment method (created in handleUpsertInstallation)
const customPaymentMethod = await getCusPaymentMethod({
stripeCli,
@@ -148,21 +158,34 @@ export const createVercelSubscription = async ({
resourceId,
});
// 6. Get subscription items
const itemSet = await getStripeSubItems2({
attachParams,
config,
});
if (isFreeProduct(product.prices)) {
if (
!refreshedCustomer?.customer_products.find(
(cp) => cp.product_id === billingPlanId,
)
) {
await handleFreeProduct({
ctx: c.get("ctx"),
attachParams,
});
}
} else {
// 6. Get subscription items
const itemSet = await getStripeSubItems2({
attachParams,
config,
});
// 7. Create Stripe subscription
const subscription = await createStripeSub2({
db,
stripeCli,
attachParams,
config,
itemSet,
logger,
});
// 7. Create Stripe subscription
await createStripeSub2({
db,
stripeCli,
attachParams,
config,
itemSet,
logger,
});
}
// Subscription will be 'incomplete' initially with an 'open' invoice
// Payment flow:
@@ -174,5 +197,5 @@ export const createVercelSubscription = async ({
// - Attaches payment record to invoice
// 4. Invoice becomes 'paid' → Subscription becomes 'active'
return { subscription, product };
return { product };
};

View File

@@ -56,7 +56,9 @@ export const handleConnectWebhook = async (c: Context<HonoEnv>) => {
webhookSecret,
);
} catch (err: any) {
logger.error(`Webhook verification error: ${err.message}`);
if (process.env.NODE_ENV !== "development") {
logger.warn(`Webhook verification error: ${err.message}`);
}
return c.json({ error: err.message }, 400);
}

View File

@@ -1,6 +1,5 @@
import type { Context, Next } from "hono";
import type { HonoEnv } from "@/honoUtils/HonoEnv.js";
import { setSentryTags } from "../external/sentry/sentryUtils";
export const parseCustomerIdFromUrl = ({
url,
@@ -137,11 +136,6 @@ export const analyticsMiddleware = async (c: Context<HonoEnv>, next: Next) => {
ctx.logger.info(`${method} ${c.req.path} (${ctx.org?.slug})`);
setSentryTags({
ctx,
customerId,
});
// Execute the request
await next();

View File

@@ -17,7 +17,12 @@ import { generateId } from "@/utils/genUtils.js";
*/
export const baseMiddleware = async (c: Context<HonoEnv>, next: Next) => {
// const env = (c.req.header("app_env") as AppEnv) || AppEnv.Sandbox;
const id = c.req.header("rndr-id") || generateId("local_req");
const id =
c.req.header("rndr-id") ||
c.req.header("X-Amzn-Trace-Id") ||
c.req.header("x-amzn-trace-id") ||
generateId("local_req");
const timestamp = Date.now();
const clickhouseClient = await ClickHouseManager.getClient();

View File

@@ -7,6 +7,7 @@ import { ZodError } from "zod/v4";
import { formatZodError } from "@/errors/formatZodError.js";
import type { HonoEnv } from "@/honoUtils/HonoEnv.js";
import RecaseError from "@/utils/errorUtils.js";
import { getSentryTags } from "../external/sentry/sentryUtils.js";
import { handleErrorSkip } from "./errorSkipMiddleware.js";
/**
* Hono error handler middleware
@@ -34,7 +35,13 @@ export const errorMiddleware = (err: Error, c: Context<HonoEnv>) => {
if (skipResponse) return skipResponse;
// If we got here, it's an error worth tracking - capture to Sentry
Sentry.captureException(err);
Sentry.captureException(err, {
tags: getSentryTags({
ctx,
path: c.req.path,
method: c.req.method,
}),
});
// 1. Handle RecaseError (our custom errors)
if (err instanceof RecaseError || err instanceof SharedRecaseError) {

View File

@@ -81,6 +81,9 @@ export const secretKeyMiddleware = async (c: Context<HonoEnv>, next: Next) => {
ctx.env = env;
ctx.userId = userId;
ctx.authType = AuthType.SecretKey;
if (data?.user) {
ctx.user = data.user;
}
await next();
};

View File

@@ -4,6 +4,7 @@ import type {
AuthType,
Feature,
Organization,
User,
} from "@autumn/shared";
import type { ClickHouseClient } from "@clickhouse/client";
import type { DrizzleCli } from "@/db/initDrizzle.js";
@@ -15,6 +16,7 @@ export type RequestContext = {
org: Organization;
env: AppEnv;
features: Feature[];
user?: User;
userId?: string;
// Objects

View File

@@ -2,5 +2,5 @@ import type { Context } from "hono";
import type { HonoEnv } from "./HonoEnv";
export const handleHealthCheck = async (c: Context<HonoEnv>) => {
return c.text("Hello from Autumn 🍂🍂🍂");
return c.text("Hello from Autumn (test 1) 🍂🍂🍂");
};

View File

@@ -129,7 +129,11 @@ const init = async () => {
req.env = req.env = req.headers.app_env || AppEnv.Sandbox;
req.db = db;
req.clickhouseClient = await ClickHouseManager.getClient();
req.id = req.headers["rndr-id"] || generateId("local_req");
req.id =
req.headers["rndr-id"] ||
req.headers["X-Amzn-Trace-Id"] ||
req.headers["x-amzn-trace-id"] ||
generateId("local_req");
req.timestamp = Date.now();
req.expand = [];
req.skipCache = false;

View File

@@ -72,6 +72,15 @@ export const createHonoApp = () => {
app.use("*", baseMiddleware);
app.use("*", traceMiddleware);
// app.get("/debug", (c) => {
// return c.json({
// region: process.env.AWS_REGION,
// amazonId:
// c.req.header("x-amzn-trace-id") || c.req.header("X-Amzn-Trace-Id"),
// reqId: c.get("ctx").id,
// });
// });
app.get("/", handleHealthCheck);
// Add Render region identifier header for load balancer verification

View File

@@ -28,12 +28,15 @@ if (process.env.AXIOM_TOKEN) {
resource: resource,
instrumentations: [
// Then add other auto-instrumentations
getNodeAutoInstrumentations(),
getNodeAutoInstrumentations({
"@opentelemetry/instrumentation-ioredis": {
enabled: false,
},
}),
],
});
// Starting the OpenTelemetry SDK to begin collecting telemetry data
console.log("Starting OpenTelemetry");
sdk.start();
console.log("OpenTelemetry started with IORedis instrumentation");
}

View File

@@ -139,7 +139,11 @@ export const handleProductsUpdated = async ({
if (ctx.apiVersion.lte(ApiVersion.V1_2)) {
addToExpand({
ctx,
add: [CusExpand.BalancesFeature, CusExpand.SubscriptionsPlan],
add: [
CusExpand.BalancesFeature,
CusExpand.SubscriptionsPlan,
CusExpand.ScheduledSubscriptionsPlan,
],
});
}

View File

@@ -12,7 +12,6 @@ import { platformRouter } from "../platform/platformLegacy/platformRouter.js";
import { expressProductRouter } from "../products/productRouter.js";
import { componentRouter } from "./components/componentRouter.js";
import { invoiceRouter } from "./invoiceRouter.js";
import { redemptionRouter, referralRouter } from "./rewards/referralRouter.js";
import { rewardProgramRouter } from "./rewards/rewardProgramRouter.js";
import rewardRouter from "./rewards/rewardRouter.js";
@@ -30,8 +29,6 @@ apiRouter.use("/rewards", rewardRouter);
// REWARDS
apiRouter.use("/reward_programs", rewardProgramRouter);
apiRouter.use("/referrals", referralRouter);
apiRouter.use("/redemptions", redemptionRouter);
// Cus Product
apiRouter.use("", attachRouter);

View File

@@ -6,15 +6,13 @@ import {
CheckParamsSchema,
CheckQuerySchema,
type CheckResponseV2,
type TrackParams,
} from "@autumn/shared";
import { createRoute } from "@/honoMiddlewares/routeHandler.js";
import { runTrack } from "../../balances/track/runTrack.js";
import { getTrackFeatureDeductions } from "../../balances/track/trackUtils/getFeatureDeductions.js";
import { getCheckData } from "./checkUtils/getCheckData.js";
import { getV2CheckResponse } from "./checkUtils/getV2CheckResponse.js";
import { getCheckPreview } from "./getCheckPreview.js";
import { handleProductCheck } from "./handlers/handleProductCheck.js";
import { runCheckWithTrack } from "./runCheckWithTrack.js";
const DEFAULT_REQUIRED_BALANCE = 1;
export const handleCheck = createRoute({
@@ -57,51 +55,33 @@ export const handleCheck = createRoute({
requiredBalance,
});
const v2Response = await getV2CheckResponse({
checkData,
requiredBalance,
});
let response: CheckResponseV2;
if (send_event) {
response = await runCheckWithTrack({
ctx,
body,
requiredBalance,
checkData,
});
} else {
response = await getV2CheckResponse({
checkData,
requiredBalance,
});
}
const preview = with_preview
? await getCheckPreview({
ctx,
checkResponse: v2Response,
checkResponse: response,
checkData,
customerId: customer_id,
entityId: entity_id,
})
: undefined;
if (v2Response.allowed && ctx.isPublic !== true) {
if (send_event && feature_id) {
// console.log(
// `Allowed is true, sending event for customer ${customer_id}, feature ${feature_id}`,
// );
const featureDeductions = getTrackFeatureDeductions({
ctx,
featureId: feature_id,
value: requiredBalance,
});
await runTrack({
ctx,
body: {
customer_id,
entity_id,
feature_id,
value: requiredBalance,
properties: body.properties,
skip_event: body.skip_event,
} satisfies TrackParams,
featureDeductions,
});
}
}
// Apply version transformations based on API version
const transformedResponse = applyResponseVersionChanges<CheckResponseV2>({
input: v2Response,
input: response,
targetVersion: ctx.apiVersion,
resource: AffectedResource.Check,
legacyData: {
@@ -135,3 +115,31 @@ export const handleCheck = createRoute({
// entity_id: entity_id,
// },
// });
// if (v2Response.allowed && ctx.isPublic !== true) {
// if (send_event && feature_id) {
// // console.log(
// // `Allowed is true, sending event for customer ${customer_id}, feature ${feature_id}`,
// // );
// const featureDeductions = getTrackFeatureDeductions({
// ctx,
// featureId: feature_id,
// value: requiredBalance,
// });
// await runTrack({
// ctx,
// body: {
// customer_id,
// entity_id,
// feature_id,
// value: requiredBalance,
// properties: body.properties,
// skip_event: body.skip_event,
// } satisfies TrackParams,
// featureDeductions,
// });
// }
// }
// Apply version transformations based on API version

View File

@@ -1,25 +1,25 @@
import { checkToAttachParams } from "@/internal/customers/attach/attachUtils/attachParams/checkToAttachParams.js";
import { FullCustomer, FullProduct } from "@autumn/shared";
import { ExtendedRequest } from "@/utils/models/Request.js";
import { AttachBody } from "@autumn/shared";
import { attachParamsToPreview } from "@/internal/customers/attach/handleAttachPreview/attachParamsToPreview.js";
import {
type AttachBodyV0,
AttachFunction,
AttachPreview,
CheckProductPreview,
Feature,
Organization,
type AttachPreview,
type CheckProductPreview,
type Feature,
type FullCustomer,
type FullProduct,
type Organization,
} from "@autumn/shared";
import { getAttachScenario } from "./attachToCheckPreview/getAttachScenario.js";
import { Decimal } from "decimal.js";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import { attachParamsToPreview } from "@/internal/billing/attachPreview/attachParamsToPreview.js";
import { checkToAttachParams } from "@/internal/customers/attach/attachUtils/attachParams/checkToAttachParams.js";
import type { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js";
import { getProductResponse } from "@/internal/products/productUtils/productResponseUtils/getProductResponse.js";
import { isFreeProduct, isOneOff } from "@/internal/products/productUtils.js";
import { formatAmount } from "@/utils/formatUtils.js";
import { Decimal } from "decimal.js";
import { notNullish } from "@/utils/genUtils.js";
import { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js";
import { DrizzleCli } from "@/db/initDrizzle.js";
import type { ExtendedRequest } from "@/utils/models/Request.js";
import type { AutumnContext } from "../../../../honoUtils/HonoEnv.js";
import { getAttachScenario } from "./attachToCheckPreview/getAttachScenario.js";
export const attachToCheckPreview = async ({
preview,
@@ -39,26 +39,26 @@ export const attachToCheckPreview = async ({
fullCus: FullCustomer;
}) => {
// 1. If check
let attachFunc = preview.func;
const attachFunc = preview.func;
if (
attachFunc == AttachFunction.AddProduct &&
attachFunc === AttachFunction.AddProduct &&
isFreeProduct(product.prices)
) {
return null;
}
const noOptions = !preview.options || preview.options.length === 0;
if (attachFunc == AttachFunction.CreateCheckout && noOptions) {
if (attachFunc === AttachFunction.CreateCheckout && noOptions) {
return null;
}
let scenario = await getAttachScenario({
const scenario = await getAttachScenario({
preview,
product,
});
let items = preview.due_today?.line_items?.map((item) => {
const items = preview.due_today?.line_items?.map((item) => {
return {
price: notNullish(item.amount)
? formatAmount({
@@ -73,21 +73,26 @@ export const attachToCheckPreview = async ({
};
});
let options = preview.options?.map((option: any) => {
const options = preview.options?.map((option: any) => {
return {
...option,
price: new Decimal(option.price).toDecimalPlaces(2).toNumber(),
};
});
let due_today = preview.due_today
const due_today = preview.due_today
? {
price: preview.due_today.total,
currency: org.default_currency || "usd",
}
: undefined;
let due_next_cycle = undefined;
let due_next_cycle:
| {
price: number;
currency: string;
}
| undefined;
if (preview.due_next_cycle) {
due_next_cycle = {
@@ -101,7 +106,7 @@ export const attachToCheckPreview = async ({
};
}
let checkPreview: CheckProductPreview = {
const checkPreview: CheckProductPreview = {
// title: "Check",
// message: "Check",
scenario,
@@ -151,17 +156,16 @@ export const getProductCheckPreview = async ({
logger,
});
const attachBody: AttachBody = {
const attachBody: AttachBodyV0 = {
customer_id: customer.id!,
product_id: product.id,
entity_id: customer.entity?.id,
};
const preview = await attachParamsToPreview({
req,
ctx: req as AutumnContext,
attachParams,
attachBody,
logger,
});
const checkPreview = await attachToCheckPreview({

View File

@@ -0,0 +1,98 @@
import {
ApiVersion,
type CheckParams,
type CheckResponseV2,
CheckResponseV2Schema,
FeatureType,
InsufficientBalanceError,
InternalError,
RecaseError,
type TrackParams,
} from "@autumn/shared";
import type { AutumnContext } from "../../../honoUtils/HonoEnv";
import { runTrack } from "../../balances/track/runTrack";
import { getTrackFeatureDeductions } from "../../balances/track/trackUtils/getFeatureDeductions";
import { featureToCreditSystem } from "../../features/creditSystemUtils";
import type { CheckData } from "./checkTypes/CheckData";
export const runCheckWithTrack = async ({
ctx,
body,
requiredBalance,
checkData,
}: {
ctx: AutumnContext;
body: CheckParams;
requiredBalance: number;
checkData: CheckData;
}): Promise<CheckResponseV2> => {
if (!body.feature_id) {
throw new InternalError({
message: "ran check with track but no feature ID",
});
}
if (ctx.isPublic) {
throw new RecaseError({
message:
"Can't pass in 'send_event: true' when using publishable key for Autumn",
});
}
const featureDeductions = getTrackFeatureDeductions({
ctx,
featureId: body.feature_id,
value: requiredBalance,
});
const trackBody: TrackParams = {
customer_id: body.customer_id,
entity_id: body.entity_id,
feature_id: body.feature_id,
value: requiredBalance,
properties: body.properties,
skip_event: body.skip_event,
overage_behavior: "reject",
};
let allowed = true;
try {
const response = await runTrack({
ctx,
body: trackBody,
featureDeductions,
apiVersion: ApiVersion.V2_0,
});
checkData.apiBalance = response.balance ?? undefined;
} catch (error) {
if (error instanceof InsufficientBalanceError) {
allowed = false;
} else {
throw error;
}
}
const { featureToUse, originalFeature } = checkData;
if (
featureToUse.type === FeatureType.CreditSystem &&
featureToUse.id !== originalFeature.id
) {
requiredBalance = featureToCreditSystem({
featureId: originalFeature.id,
creditSystem: featureToUse,
amount: requiredBalance,
});
}
const checkResponse = CheckResponseV2Schema.parse({
allowed,
customer_id: checkData.customerId || "",
entity_id: checkData.entityId,
required_balance: requiredBalance,
balance: checkData.apiBalance ?? null,
});
return checkResponse;
};

View File

@@ -1,20 +1,17 @@
import { z } from "zod/v4";
import { RewardRedemptionService } from "@/internal/rewards/RewardRedemptionService.js";
import { routeHandler } from "@/utils/routerUtils.js";
import { createRoute } from "../../../../../honoMiddlewares/routeHandler";
export const handleGetRedemption = createRoute({
params: z.object({ redemption_id: z.string() }),
handler: async (c) => {
const { db } = c.get("ctx");
const { redemption_id } = c.req.param();
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: redemption_id,
});
const redemption = await RewardRedemptionService.getById({
db,
id: redemptionId,
});
res.status(200).json(redemption);
},
});
return c.json(redemption);
},
});

View File

@@ -1,85 +1,83 @@
import { ErrCode } from "@autumn/shared";
import { CustomerNotFoundError, ErrCode, RecaseError } from "@autumn/shared";
import { z } from "zod/v4";
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";
import { createRoute } from "../../../../../honoMiddlewares/routeHandler";
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;
export const handleGetReferralCode = createRoute({
body: z.object({
program_id: z.string(),
customer_id: z.string(),
}),
handler: async (c) => {
const ctx = c.get("ctx");
const { db, org, env } = ctx;
const { program_id: rewardProgramId, customer_id: customerId } =
c.req.valid("json");
const [rewardProgram, customer] = await Promise.all([
RewardProgramService.get({
db,
idOrInternalId: rewardProgramId,
orgId,
env,
errorIfNotFound: true,
}),
CusService.get({
db: req.db,
orgId,
env,
idOrInternalId: customerId,
}),
]);
const [rewardProgram, customer] = await Promise.all([
RewardProgramService.get({
db,
idOrInternalId: rewardProgramId,
orgId: org.id,
env,
errorIfNotFound: true,
}),
CusService.get({
db,
orgId: org.id,
env,
idOrInternalId: customerId,
}),
]);
if (!customer) {
throw new RecaseError({
message: "Customer not found",
statusCode: 404,
code: ErrCode.CustomerNotFound,
});
}
if (!customer) {
throw new CustomerNotFoundError({ customerId });
}
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,
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: org.id,
env,
internalCustomerId: customer.internal_id,
internalRewardProgramId: rewardProgram.internal_id,
});
if (!referralCode) {
const code = generateReferralCode();
referralCode = {
code,
org_id: org.id,
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,
});
}
return c.json({
code: referralCode.code,
customer_id: customer.id,
created_at: referralCode.created_at,
});
},
});

View File

@@ -1,13 +1,15 @@
import {
CustomerNotFoundError,
ErrCode,
InternalError,
RecaseError,
RewardCategory,
type RewardRedemption,
RewardTriggerEvent,
} from "@autumn/shared";
import { z } from "zod/v4";
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";
@@ -16,187 +18,359 @@ import { triggerRedemption } from "@/internal/rewards/referralUtils.js";
import { getRewardCat } from "@/internal/rewards/rewardUtils.js";
import { generateId, notNullish } from "@/utils/genUtils.js";
import type { ExtendedRequest } from "@/utils/models/Request.js";
import { routeHandler } from "@/utils/routerUtils.js";
import { createRoute } from "../../../../../honoMiddlewares/routeHandler";
export default async (req: any, res: any) =>
routeHandler({
req,
res,
action: "redeem referral code",
handler: async (req, res) => {
const { orgId, env, logger, db } = req;
const { code, customer_id: customerId } = req.body;
export const handleRedeemReferral = createRoute({
body: z.object({
code: z.string(),
customer_id: z.string(),
}),
handler: async (c) => {
const ctx = c.get("ctx");
const { db, org, env } = ctx;
const { code, customer_id: customerId } = c.req.valid("json");
// 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({
// 1. Get redeemed by customer, and referral code
const [customer, referralCode] = await Promise.all([
CusService.get({
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(),
redeemer_applied: false,
};
redemption = await RewardRedemptionService.insert({
orgId: org.id,
env,
idOrInternalId: customerId,
}),
RewardProgramService.getReferralCode({
db,
rewardRedemption: redemption,
});
orgId: org.id,
env,
code,
withRewardProgram: true,
}),
]);
// 5. If reward trigger when is immediate:
const { reward_program } = referralCode;
const redeemRewardNow =
if (!customer) throw new CustomerNotFoundError({ customerId });
// 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,
internalId: referralCode.internal_customer_id,
});
if (!codeCustomer) {
throw new InternalError({
message: `Referral code customer not found, internal ID: ${referralCode.internal_customer_id}`,
});
}
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;
RewardTriggerEvent.CustomerCreation,
applied: false,
updated_at: Date.now(),
redeemer_applied: false,
};
if (redeemRewardNow) {
const reward = await RewardService.get({
db,
orgId,
env,
idOrInternalId: reward_program.internal_reward_id,
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: org.id,
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,
});
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,
},
});
},
});
const rewardCat = getRewardCat(reward);
if (rewardCat === RewardCategory.FreeProduct) {
await triggerFreeProduct({
req: parseReqForAction(ctx as ExtendedRequest) as ExtendedRequest,
db,
referralCode,
redeemer: customer,
rewardProgram: reward_program,
org,
env,
logger: ctx.logger,
redemption,
});
} else {
await triggerRedemption({
db,
referralCode,
org,
env,
logger: ctx.logger,
reward,
redemption,
});
}
}
return c.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,
},
});
},
});
// export default async (req: any, res: any) =>
// routeHandler({
// req,
// res,
// action: "redeem referral code",
// handler: async (req, res) => {
// const { orgId, env, 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(),
// redeemer_applied: false,
// };
// 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,
// },
// });
// },
// });

View File

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

View File

@@ -1,17 +1,13 @@
import express, { type Router } from "express";
import {
handleGetRedemption,
handleGetReferralCode,
handleRedeemReferral,
} from "./handlers/referrals/index.js";
import { Hono } from "hono";
import type { HonoEnv } from "../../../honoUtils/HonoEnv.js";
import { handleGetRedemption } from "./handlers/referrals/handleGetRedemption.js";
import { handleGetReferralCode } from "./handlers/referrals/handleGetReferralCode.js";
import { handleRedeemReferral } from "./handlers/referrals/handleRedeemReferral.js";
export const referralRouter: Router = express.Router();
export const redemptionRouter = new Hono<HonoEnv>();
// 1. Get referral code
referralRouter.post("/code", handleGetReferralCode);
redemptionRouter.get("/:redemption_id", ...handleGetRedemption);
referralRouter.post("/redeem", handleRedeemReferral);
export const redemptionRouter: Router = express.Router();
redemptionRouter.get("/:redemptionId", handleGetRedemption);
export const referralRouter = new Hono<HonoEnv>();
referralRouter.post("/code", ...handleGetReferralCode);
referralRouter.post("/redeem", ...handleRedeemReferral);

View File

@@ -1,4 +1,5 @@
import { events } from "@autumn/shared";
import * as Sentry from "@sentry/bun";
import type { Logger } from "pino";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import type { JobName } from "@/queue/JobName.js";
@@ -23,6 +24,7 @@ export const runInsertEventBatch = async ({
if (!eventInserts || eventInserts.length === 0) return;
// Normalize timestamps
eventInserts.forEach((event) => {
try {
if (event.timestamp && typeof event.timestamp === "string") {
@@ -33,12 +35,46 @@ export const runInsertEventBatch = async ({
}
});
// Batch insert events directly - no DB lookups needed
try {
await db.insert(events).values(eventInserts as any);
logger.info(`✅ Successfully inserted ${eventInserts.length} events`);
} catch (error: any) {
logger.error(`❌ Failed to batch insert events: ${error.message}`);
throw error;
// Group events by internal_customer_id
const eventsByCustomer = new Map<string, typeof eventInserts>();
for (const event of eventInserts) {
const customerId = event.internal_customer_id;
if (!customerId) {
logger.warn(
"Event missing internal_customer_id, skipping event grouping",
);
continue;
}
if (!eventsByCustomer.has(customerId)) {
eventsByCustomer.set(customerId, []);
}
eventsByCustomer.get(customerId)?.push(event);
}
// Insert events for each customer in parallel
const insertPromises = Array.from(eventsByCustomer.entries()).map(
async ([customerId, customerEvents]) => {
try {
await db.insert(events).values(customerEvents as any);
return {
success: true,
customerId,
count: customerEvents.length,
};
} catch (error: any) {
logger.error(
`❌ Failed to insert ${customerEvents.length} events for customer ${customerId}: ${error.message}`,
);
Sentry.captureException(error);
return {
success: false,
customerId,
count: customerEvents.length,
error: error.message,
};
}
},
);
await Promise.all(insertPromises);
};

View File

@@ -1,5 +1,4 @@
import type { ApiBalance } from "@autumn/shared";
import { getBatchDeductionScript } from "@lua/luaScripts.js";
import type { Redis } from "ioredis";
import { logger } from "../../../../external/logtail/logtailUtils";
@@ -52,9 +51,7 @@ export const executeBatchDeduction = async ({
}): Promise<BatchDeductionResult> => {
try {
// Execute Lua script (hot reload in dev)
const result = await redis.eval(
getBatchDeductionScript(),
0, // No KEYS, all params in ARGV
const result = await redis.batchDeduction(
JSON.stringify(requests), // ARGV[1]
orgId, // ARGV[2]
env, // ARGV[3]

View File

@@ -1,5 +1,7 @@
import {
AffectedResource,
type ApiVersion,
ApiVersionClass,
applyResponseVersionChanges,
type CheckExpand,
ErrCode,
@@ -15,15 +17,18 @@ import { runRedisDeduction } from "./redisTrackUtils/runRedisDeduction";
import { constructEvent, type EventInfo } from "./trackUtils/eventUtils";
import { executePostgresTracking } from "./trackUtils/executePostgresTracking";
import type { FeatureDeduction } from "./trackUtils/getFeatureDeductions";
import { getTrackBalancesResponse } from "./trackUtils/getTrackBalancesResponse";
export const runTrack = async ({
ctx,
body,
featureDeductions,
apiVersion,
}: {
ctx: AutumnContext;
body: TrackParams;
featureDeductions: FeatureDeduction[];
apiVersion?: ApiVersion;
}) => {
// Validate: event_name cannot be used with overage_behavior: "reject"
if (body.event_name && body.overage_behavior === "reject") {
@@ -92,29 +97,28 @@ export const runTrack = async ({
} else {
// Clean balances
if (balances && Object.keys(balances).length > 0) {
for (const balance of Object.values(balances)) {
balance.feature = undefined;
}
}
// console.log("Balances:", balances);
const finalBalances = getTrackBalancesResponse({
featureDeductions,
features: ctx.features,
balances,
});
response = {
customer_id: body.customer_id,
entity_id: body.entity_id,
event_name: body.event_name,
value: body.value ?? 1,
balance:
balances && Object.keys(balances).length === 1
? Object.values(balances)[0]
: null,
balances:
balances && Object.keys(balances).length > 1 ? balances : undefined,
balance: finalBalances.balance,
balances: finalBalances.balances,
};
}
const transformedResponse = applyResponseVersionChanges<TrackResponseV2>({
input: response,
targetVersion: ctx.apiVersion,
targetVersion: apiVersion
? new ApiVersionClass(apiVersion)
: ctx.apiVersion,
resource: AffectedResource.Track,
legacyData: {
feature_id: body.feature_id || body.event_name,

View File

@@ -137,7 +137,7 @@ export const syncItem = async ({
const cusEnts = cusProductsToCusEnts({
cusProducts: fullCus.customer_products,
featureIds: relevantFeatures.map((f) => f.id),
featureId: relevantFeature.id,
reverseOrder: org.config?.reverse_deduction_order,
entity: fullCus.entity,
inStatuses: orgToInStatuses({ org }),
@@ -146,7 +146,7 @@ export const syncItem = async ({
const backendBalance = apiToBackendBalance({
apiBalance: redisBalance,
cusEnts,
features: relevantFeatures,
features: [relevantFeature],
});
featureDeductions.push({
@@ -167,9 +167,6 @@ export const syncItem = async ({
refreshCache: false, // CRITICAL: Don't refresh cache after sync (Redis is the source of truth)
});
// const logText = `sync complete | customer: ${customerId}, feature:${featureId}${entityId ? `, entity:${entityId}` : ""} [${org.slug}, ${env}]`;
// console.log(logText);
// ctx.logger.info(logText);
ctx.logger.info(
`[SYNC COMPLETE] (${customerId}${entityId ? `, ${entityId}` : ""}) feature ${featureId}, target: ${chalk.yellow(featureDeductions?.[0]?.targetBalance)}`,
);

View File

@@ -4,6 +4,7 @@ import type { AutumnContext } from "../../../../honoUtils/HonoEnv.js";
import { getApiCustomerBase } from "../../../customers/cusUtils/apiCusUtils/getApiCustomerBase.js";
import { getOrCreateCustomer } from "../../../customers/cusUtils/getOrCreateCustomer.js";
import type { FeatureDeduction } from "./getFeatureDeductions.js";
import { getTrackBalancesResponse } from "./getTrackBalancesResponse.js";
import { runDeductionTx } from "./runDeductionTx.js";
const catchInsufficientBalanceError = ({
@@ -106,11 +107,21 @@ export const executePostgresTracking = async ({
balancesRes[featureId] = apiCustomer.balances[featureId];
}
if (Object.keys(balancesRes).length > 1) {
response.balances = balancesRes;
} else {
response.balance = Object.values(balancesRes)?.[0];
}
// if (Object.keys(balancesRes).length > 0) {
// response.balance =
// balancesRes[
// Object.keys(balancesRes)[Object.keys(balancesRes).length - 1]
// ];
// }
const finalBalances = getTrackBalancesResponse({
featureDeductions,
features: ctx.features,
balances: balancesRes,
});
response.balance = finalBalances.balance;
response.balances = finalBalances.balances;
}
return response;

View File

@@ -0,0 +1,74 @@
import {
type ApiBalance,
CheckExpand,
expandIncludes,
type Feature,
getRelevantFeatures,
} from "@autumn/shared";
import type { FeatureDeduction } from "./getFeatureDeductions";
export const getTrackBalancesResponse = ({
featureDeductions,
features,
balances,
expand,
}: {
featureDeductions: FeatureDeduction[];
features: Feature[];
balances?: Record<string, ApiBalance>;
expand?: CheckExpand[];
}) => {
if (!balances) {
return {
balance: null,
balances: undefined,
};
}
// For each feature deduction
const finalBalances: Record<string, ApiBalance> = {};
for (const deduction of featureDeductions) {
let finalBalance: ApiBalance | undefined;
const relevantFeatures = getRelevantFeatures({
features,
featureId: deduction.feature.id,
});
for (const feature of relevantFeatures) {
if (balances[feature.id]) {
finalBalance = balances[feature.id];
}
}
if (finalBalance) {
finalBalances[finalBalance.feature_id] = finalBalance;
}
}
if (
!expandIncludes({
expand: expand || [],
includes: [CheckExpand.BalanceFeature],
})
) {
for (const featureId in finalBalances) {
finalBalances[featureId].feature = undefined;
}
}
if (Object.keys(finalBalances).length === 0) {
return {
balance: null,
balances: undefined,
};
} else if (Object.keys(finalBalances).length === 1) {
return {
balance: Object.values(finalBalances)[0],
balances: undefined,
};
} else {
return {
balance: null,
balances: finalBalances,
};
}
};

View File

@@ -103,10 +103,12 @@ export const deductFromCusEnts = async ({
for (const deduction of deductions) {
const { feature, deduction: toDeduct, targetBalance } = deduction;
const relevantFeatures = getRelevantFeatures({
features: ctx.features,
featureId: feature.id,
});
const relevantFeatures = notNullish(targetBalance)
? [feature]
: getRelevantFeatures({
features: ctx.features,
featureId: feature.id,
});
const cusEnts = cusProductsToCusEnts({
cusProducts: fullCus.customer_products,
@@ -117,17 +119,6 @@ export const deductFromCusEnts = async ({
sortParams,
});
// if (printLogs) {
// console.log(
// `Cus Ents: `,
// cusEnts.map((ce) => ({
// balance: ce.balance,
// entity_id: ce.customer_product.entity_id,
// cus_ent_id: ce.id,
// })),
// );
// }
const { unlimited } = getUnlimitedAndUsageAllowed({
cusEnts,
internalFeatureId: feature.internal_id!,
@@ -276,6 +267,11 @@ export const deductFromCusEnts = async ({
});
}
} catch (error) {
if (error instanceof Error && !error?.message?.includes("declined")) {
ctx.logger.error(
`[deductFromCusEnts] Attempting rollback due to error: ${error}`,
);
}
await rollbackDeduction({
ctx,
oldFullCus,

View File

@@ -0,0 +1,93 @@
import { AttachBodyV0Schema } from "../../../../../shared/api/billing/attach/prevVersions/attachBodyV0";
import { AffectedResource } from "../../../../../shared/api/versionUtils/versionUtils";
import { createRoute } from "../../../honoMiddlewares/routeHandler";
import { checkStripeConnections } from "../../customers/attach/attachRouter";
import { getAttachParams } from "../../customers/attach/attachUtils/attachParams/getAttachParams";
import { getAttachBranch } from "../../customers/attach/attachUtils/getAttachBranch";
import { getAttachConfig } from "../../customers/attach/attachUtils/getAttachConfig";
import { handleAttachErrors } from "../../customers/attach/attachUtils/handleAttachErrors";
import { insertCustomItems } from "../../customers/attach/attachUtils/insertCustomItems";
export const handleAttachV2 = createRoute({
body: AttachBodyV0Schema,
resource: AffectedResource.Attach,
handler: async (c) => {
// await handleAttachRaceCondition({ req, res });
const ctx = c.get("ctx");
const attachBody = c.req.valid("json");
const { attachParams, customPrices, customEnts } = await getAttachParams({
ctx,
attachBody,
});
// Handle existing product
const branch = await getAttachBranch({
ctx,
attachBody,
attachParams,
});
const { flags, config } = await getAttachConfig({
ctx,
attachParams,
attachBody,
branch,
});
await handleAttachErrors({
attachParams,
attachBody,
branch,
flags,
config,
});
await checkStripeConnections({
ctx,
attachParams,
useCheckout: config.onlyCheckout,
});
await insertCustomItems({
db: ctx.db,
customPrices: customPrices || [],
customEnts: customEnts || [],
});
try {
ctx.logger.info(`Attach params: `, {
data: {
products: attachParams.products.map((p) => ({
id: p.id,
name: p.name,
processor: p.processor,
version: p.version,
})),
prices: attachParams.prices.map((p) => ({
id: p.id,
config: p.config,
})),
entitlements: attachParams.entitlements.map((e) => ({
internal_feature_id: e.internal_feature_id,
feature_id: e.feature_id,
})),
freeTrial: attachParams.freeTrial,
},
});
} catch (_error) {}
// const response = await runAttachFunction({
// req,
// res,
// attachParams,
// branch,
// attachBody,
// config,
// });
return c.json({
message: "Hello, world!",
});
},
});

View File

@@ -1,45 +1,41 @@
import {
type AttachBody,
AttachBranch,
type AttachBodyV0,
AttachFunction,
cusProductToProduct,
} from "@autumn/shared";
import { notNullish } from "@/utils/genUtils.js";
import type { ExtendedRequest } from "@/utils/models/Request.js";
import type { AttachParams } from "../../cusProducts/AttachParams.js";
import { attachParamToCusProducts } from "../attachUtils/convertAttachParams.js";
import { getAttachBranch } from "../attachUtils/getAttachBranch.js";
import { getAttachConfig } from "../attachUtils/getAttachConfig.js";
import { getAttachFunction } from "../attachUtils/getAttachFunction.js";
import { getDowngradeProductPreview } from "./getDowngradeProductPreview.js";
import { getMultiAttachPreview } from "./getMultiAttachPreview.js";
import { getNewProductPreview } from "./getNewProductPreview.js";
import { getUpgradeProductPreview } from "./getUpgradeProductPreview.js";
import { attachParamToCusProducts } from "@/internal/customers/attach/attachUtils/convertAttachParams.js";
import { getAttachBranch } from "@/internal/customers/attach/attachUtils/getAttachBranch.js";
import { getAttachConfig } from "@/internal/customers/attach/attachUtils/getAttachConfig.js";
import { getAttachFunction } from "@/internal/customers/attach/attachUtils/getAttachFunction.js";
import { getDowngradeProductPreview } from "@/internal/customers/attach/handleAttachPreview/getDowngradeProductPreview.js";
import { getNewProductPreview } from "@/internal/customers/attach/handleAttachPreview/getNewProductPreview.js";
import { getUpgradeProductPreview } from "@/internal/customers/attach/handleAttachPreview/getUpgradeProductPreview.js";
import type { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js";
import type { AutumnContext } from "../../../honoUtils/HonoEnv";
export const attachParamsToPreview = async ({
req,
ctx,
attachParams,
attachBody,
logger,
withPrepaid = false,
}: {
req: ExtendedRequest;
ctx: AutumnContext;
attachParams: AttachParams;
attachBody: AttachBody;
logger: any;
attachBody: AttachBodyV0;
withPrepaid?: boolean;
}) => {
const { logger } = ctx;
// Handle existing product
const branch = await getAttachBranch({
req,
ctx,
attachBody,
attachParams,
fromPreview: true,
});
const { flags, config } = await getAttachConfig({
req,
const { config } = await getAttachConfig({
ctx,
attachParams,
attachBody,
branch,
@@ -61,18 +57,6 @@ export const attachParamsToPreview = async ({
let preview: any = null;
if (
branch === AttachBranch.MultiAttach ||
notNullish(attachParams.productsList)
) {
preview = await getMultiAttachPreview({
req,
attachBody,
attachParams,
logger,
config,
branch,
});
} else if (
func === AttachFunction.AddProduct ||
func === AttachFunction.CreateCheckout ||
func === AttachFunction.OneOff
@@ -103,7 +87,7 @@ export const attachParamsToPreview = async ({
func === AttachFunction.UpdatePrepaidQuantity
) {
preview = await getUpgradeProductPreview({
req,
ctx,
attachParams,
branch,
now,

View File

@@ -1,7 +1,9 @@
import { Hono } from "hono";
import type { HonoEnv } from "../../honoUtils/HonoEnv.js";
import { handleCheckoutV2 } from "./checkout/handleCheckoutV2.js";
import { handleSetupPayment } from "./handlers/handleSetupPayment.js";
export const billingRouter = new Hono<HonoEnv>();
billingRouter.post("/setup_payment", ...handleSetupPayment);
billingRouter.post("/checkout", ...handleCheckoutV2);

View File

@@ -0,0 +1,27 @@
import { AttachBranch, cusProductToPrices } from "@autumn/shared";
import { attachParamToCusProducts } from "@/internal/customers/attach/attachUtils/convertAttachParams.js";
import type { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js";
import { isFreeProduct } from "@/internal/products/productUtils.js";
export const getHasProrations = async ({
branch,
attachParams,
}: {
branch: AttachBranch;
attachParams: AttachParams;
}) => {
const { curMainProduct } = attachParamToCusProducts({ attachParams });
if (branch === AttachBranch.Upgrade) {
const curPrices = cusProductToPrices({ cusProduct: curMainProduct! });
if (!isFreeProduct(curPrices)) {
return true;
}
}
if (branch === AttachBranch.UpdatePrepaidQuantity) {
return true;
}
return false;
};

View File

@@ -0,0 +1,205 @@
// import {
// type AttachBodyV0,
// AttachBodySchema,
// AttachFunction,
// type FeatureOptions,
// } from "@autumn/shared";
// import { attachParamsToPreview } from "@/internal/billing/attachPreview/attachParamsToPreview.js";
// import { handleCreateCheckout } from "@/internal/customers/add-product/handleCreateCheckout.js";
// import { handleCreateInvoiceCheckout } from "@/internal/customers/add-product/handleCreateInvoiceCheckout.js";
// import {
// checkStripeConnections,
// handlePrepaidErrors,
// } from "@/internal/customers/attach/attachRouter.js";
// import { getAttachParams } from "@/internal/customers/attach/attachUtils/attachParams/getAttachParams.js";
// import { attachParamsToProduct } from "@/internal/customers/attach/attachUtils/convertAttachParams.js";
// import { getAttachBranch } from "@/internal/customers/attach/attachUtils/getAttachBranch.js";
// import { getAttachConfig } from "@/internal/customers/attach/attachUtils/getAttachConfig.js";
// import { getAttachFunction } from "@/internal/customers/attach/attachUtils/getAttachFunction.js";
// import { handleCheckoutErrors } from "@/internal/customers/attach/attachUtils/handleAttachErrors/handleCheckoutErrors.js";
// import type { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js";
// import { priceToFeature } from "@/internal/products/prices/priceUtils/convertPrice.js";
// import { isPrepaidPrice } from "@/internal/products/prices/priceUtils/usagePriceUtils/classifyUsagePrice.js";
// import { getPriceOptions } from "@/internal/products/prices/priceUtils.js";
// import type {
// ExtendedRequest,
// ExtendedResponse,
// } from "@/utils/models/Request.js";
// import { routeHandler } from "@/utils/routerUtils.js";
// import type { AutumnContext } from "../../../honoUtils/HonoEnv.js";
// import { getHasProrations } from "./getHasProrations.js";
// import { previewToCheckoutRes } from "./previewToCheckoutRes.js";
// const getAttachVars = async ({
// req,
// attachBody,
// }: {
// req: ExtendedRequest;
// attachBody: AttachBodyV0;
// }) => {
// const { attachParams } = await getAttachParams({
// req,
// attachBody,
// });
// const branch = await getAttachBranch({
// req,
// attachBody,
// attachParams,
// fromPreview: true,
// });
// const { flags, config } = await getAttachConfig({
// req,
// attachParams,
// attachBody,
// branch,
// });
// const func = await getAttachFunction({
// branch,
// attachParams,
// attachBody,
// config,
// });
// return {
// attachParams,
// flags,
// branch,
// config,
// func,
// };
// };
// const getCheckoutOptions = async ({
// req,
// attachParams,
// }: {
// req: ExtendedRequest;
// attachParams: AttachParams;
// }) => {
// const product = attachParamsToProduct({ attachParams });
// const prepaidPrices = product.prices.filter((p) =>
// isPrepaidPrice({ price: p }),
// );
// const newOptions: FeatureOptions[] = structuredClone(
// attachParams.optionsList,
// );
// for (const prepaidPrice of prepaidPrices) {
// const feature = priceToFeature({
// price: prepaidPrice,
// features: req.features,
// });
// const option = getPriceOptions(prepaidPrice, attachParams.optionsList);
// if (!option) {
// newOptions.push({
// feature_id: feature?.id ?? "",
// internal_feature_id: feature?.internal_id,
// quantity: 1,
// });
// }
// }
// attachParams.optionsList = newOptions;
// return newOptions;
// };
// export const handleCheckout = (req: any, res: any) =>
// routeHandler({
// req,
// res,
// action: "attach-preview",
// handler: async (req: ExtendedRequest, res: ExtendedResponse) => {
// const { logger } = req;
// const attachBody = AttachBodySchema.parse(req.body);
// const { attachParams, branch, func, config } = await getAttachVars({
// req,
// attachBody,
// });
// let checkoutUrl = null;
// handleCheckoutErrors({
// attachParams,
// branch,
// });
// if (func === AttachFunction.CreateCheckout) {
// await checkStripeConnections({
// ctx: req as AutumnContext,
// attachParams,
// createCus: true,
// useCheckout: true,
// });
// await handlePrepaidErrors({
// attachParams,
// config,
// useCheckout: config.onlyCheckout,
// });
// if (config.invoiceCheckout) {
// const result = await handleCreateInvoiceCheckout({
// req,
// attachParams,
// attachBody,
// branch,
// config,
// });
// checkoutUrl = result?.invoices?.[0]?.hosted_invoice_url;
// } else {
// const checkout = await handleCreateCheckout({
// req,
// res,
// attachParams,
// config,
// returnCheckout: true,
// });
// checkoutUrl = checkout?.url;
// }
// }
// console.log(`Branch: ${branch}, Func: ${func}`);
// await getCheckoutOptions({
// req,
// attachParams,
// });
// const preview = await attachParamsToPreview({
// req,
// attachParams,
// logger,
// attachBody,
// withPrepaid: true,
// });
// const checkoutRes = await previewToCheckoutRes({
// req,
// attachParams,
// preview,
// branch,
// });
// // Get has prorations
// const hasProrations = await getHasProrations({
// req,
// branch,
// attachParams,
// });
// res.status(200).json({
// ...checkoutRes,
// url: checkoutUrl,
// has_prorations: hasProrations,
// });
// return;
// },
// });

View File

@@ -0,0 +1,112 @@
import {
AffectedResource,
ApiVersion,
AttachFunction,
CheckoutParamsV0Schema,
} from "@autumn/shared";
import { createRoute } from "../../../honoMiddlewares/routeHandler";
import type { ExtendedRequest } from "../../../utils/models/Request";
import { handleCreateCheckout } from "../../customers/add-product/handleCreateCheckout";
import { handleCreateInvoiceCheckout } from "../../customers/add-product/handleCreateInvoiceCheckout";
import {
checkStripeConnections,
handlePrepaidErrors,
} from "../../customers/attach/attachRouter";
import { handleCheckoutErrors } from "../../customers/attach/attachUtils/handleAttachErrors/handleCheckoutErrors";
import { attachParamsToPreview } from "../attachPreview/attachParamsToPreview";
import { getHasProrations } from "./getHasProrations";
import { previewToCheckoutRes } from "./previewToCheckoutRes";
import { checkoutToAttachContext } from "./utils/checkoutToAttachContext";
import { getCheckoutOptions } from "./utils/getCheckoutOptions";
export const handleCheckoutV2 = createRoute({
versionedBody: {
latest: CheckoutParamsV0Schema,
[ApiVersion.V1_Beta]: CheckoutParamsV0Schema,
},
resource: AffectedResource.Checkout,
handler: async (c) => {
const ctx = c.get("ctx");
const body = c.req.valid("json");
const { attachParams, branch, func, config } =
await checkoutToAttachContext({
ctx,
checkoutParams: body,
});
let checkoutUrl = null;
handleCheckoutErrors({
attachParams,
branch,
});
if (func === AttachFunction.CreateCheckout) {
await checkStripeConnections({
ctx,
attachParams,
createCus: true,
useCheckout: true,
});
await handlePrepaidErrors({
attachParams,
config,
useCheckout: config.onlyCheckout,
});
if (config.invoiceCheckout) {
const result = await handleCreateInvoiceCheckout({
req: ctx as ExtendedRequest,
attachParams,
attachBody: body,
branch,
config,
});
checkoutUrl = result?.invoices?.[0]?.hosted_invoice_url;
} else {
const checkout = await handleCreateCheckout({
req: ctx as ExtendedRequest,
attachParams,
config,
returnCheckout: true,
});
checkoutUrl = checkout?.url;
}
}
await getCheckoutOptions({
ctx,
attachParams,
});
const preview = await attachParamsToPreview({
ctx,
attachParams,
attachBody: body,
withPrepaid: true,
});
const checkoutRes = await previewToCheckoutRes({
req: ctx as ExtendedRequest,
attachParams,
preview,
branch,
});
// Get has prorations
const hasProrations = await getHasProrations({
branch,
attachParams,
});
return c.json({
...checkoutRes,
url: checkoutUrl,
has_prorations: hasProrations,
});
},
});

View File

@@ -1,8 +1,8 @@
import {
type AttachBranch,
type AttachPreview,
type CheckoutLine,
CheckoutResponseSchema,
type CheckoutLineV0,
CheckoutResponseV0Schema,
cusProductToEnts,
cusProductToPrices,
cusProductToProduct,
@@ -12,6 +12,11 @@ import {
UsageModel,
} from "@autumn/shared";
import { Decimal } from "decimal.js";
import {
attachParamsToProduct,
attachParamToCusProducts,
} from "@/internal/customers/attach/attachUtils/convertAttachParams.js";
import type { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js";
import { getPriceEntitlement } from "@/internal/products/prices/priceUtils.js";
import { isPriceItem } from "@/internal/products/product-items/productItemUtils/getItemType.js";
import {
@@ -20,11 +25,6 @@ import {
} from "@/internal/products/productUtils/productResponseUtils/getProductResponse.js";
import { notNullish } from "@/utils/genUtils.js";
import type { ExtendedRequest } from "@/utils/models/Request.js";
import type { AttachParams } from "../../cusProducts/AttachParams.js";
import {
attachParamsToProduct,
attachParamToCusProducts,
} from "../attachUtils/convertAttachParams.js";
export const previewToCheckoutRes = async ({
req,
@@ -52,7 +52,7 @@ export const previewToCheckoutRes = async ({
const newEnts = attachParams.entitlements;
const allPrices = [...curPrices, ...newPrices];
const allEnts = [...curEnts, ...newEnts];
let lines: CheckoutLine[] = [];
let lines: CheckoutLineV0[] = [];
if (preview.due_today && preview.due_today.line_items.length > 0) {
lines = preview.due_today.line_items
@@ -77,7 +77,7 @@ export const previewToCheckoutRes = async ({
}),
};
})
.filter(notNullish) as CheckoutLine[];
.filter(notNullish) as CheckoutLineV0[];
}
const curProduct = curCusProduct
@@ -172,7 +172,7 @@ export const previewToCheckoutRes = async ({
})
.filter(notNullish);
return CheckoutResponseSchema.parse({
return CheckoutResponseV0Schema.parse({
customer_id: attachParams.customer.id,
lines,
product: newProduct,

View File

@@ -0,0 +1,61 @@
import type {
AttachBranch,
AttachConfig,
AttachFunction,
CheckoutParamsV0,
} from "@autumn/shared";
import type { AutumnContext } from "../../../../honoUtils/HonoEnv";
import { getAttachParams } from "../../../customers/attach/attachUtils/attachParams/getAttachParams";
import { getAttachBranch } from "../../../customers/attach/attachUtils/getAttachBranch";
import { getAttachConfig } from "../../../customers/attach/attachUtils/getAttachConfig";
import { getAttachFunction } from "../../../customers/attach/attachUtils/getAttachFunction";
import type { AttachFlags } from "../../../customers/attach/models/AttachFlags";
import type { AttachParams } from "../../../customers/cusProducts/AttachParams";
export const checkoutToAttachContext = async ({
ctx,
checkoutParams,
}: {
ctx: AutumnContext;
checkoutParams: CheckoutParamsV0;
}): Promise<{
attachParams: AttachParams;
flags: AttachFlags;
branch: AttachBranch;
config: AttachConfig;
func: AttachFunction;
}> => {
const { attachParams } = await getAttachParams({
ctx,
attachBody: checkoutParams,
});
const branch = await getAttachBranch({
ctx,
attachBody: checkoutParams,
attachParams,
fromPreview: true,
});
const { flags, config } = await getAttachConfig({
ctx,
attachParams,
attachBody: checkoutParams,
branch,
});
const func = await getAttachFunction({
branch,
attachParams,
attachBody: checkoutParams,
config,
});
return {
attachParams,
flags,
branch,
config,
func,
};
};

View File

@@ -0,0 +1,40 @@
import { type FeatureOptions, isPrepaidPrice } from "@autumn/shared";
import type { AutumnContext } from "../../../../honoUtils/HonoEnv";
import { attachParamsToProduct } from "../../../customers/attach/attachUtils/convertAttachParams";
import type { AttachParams } from "../../../customers/cusProducts/AttachParams";
import { getPriceOptions } from "../../../products/prices/priceUtils";
import { priceToFeature } from "../../../products/prices/priceUtils/convertPrice";
export const getCheckoutOptions = async ({
ctx,
attachParams,
}: {
ctx: AutumnContext;
attachParams: AttachParams;
}) => {
const product = attachParamsToProduct({ attachParams });
const prepaidPrices = product.prices.filter((p) =>
isPrepaidPrice({ price: p }),
);
const newOptions: FeatureOptions[] = structuredClone(
attachParams.optionsList,
);
for (const prepaidPrice of prepaidPrices) {
const feature = priceToFeature({
price: prepaidPrice,
features: ctx.features,
});
const option = getPriceOptions(prepaidPrice, attachParams.optionsList);
if (!option) {
newOptions.push({
feature_id: feature?.id ?? "",
internal_feature_id: feature?.internal_id,
quantity: 1,
});
}
}
attachParams.optionsList = newOptions;
return newOptions;
};

View File

@@ -27,7 +27,7 @@ export const handleCreateCheckout = async ({
returnCheckout = false,
}: {
req: any;
res: any;
res?: any;
attachParams: AttachParams;
config: AttachConfig;
returnCheckout?: boolean;
@@ -182,7 +182,7 @@ export const handleCreateCheckout = async ({
}
}
if (returnCheckout) {
if (returnCheckout || !res) {
return checkout;
}

View File

@@ -1,22 +1,19 @@
import {
AttachParams,
AttachResultSchema,
} from "../cusProducts/AttachParams.js";
import { createCheckoutMetadata } from "@/internal/metadata/metadataUtils.js";
import { isOneOff } from "@/internal/products/productUtils.js";
import { handlePaidProduct } from "../attach/attachFunctions/addProductFlow/handlePaidProduct.js";
import {
AttachBody,
AttachBranch,
AttachConfig,
type AttachBodyV0,
type AttachBranch,
type AttachConfig,
SuccessCode,
} from "@autumn/shared";
import Stripe from "stripe";
import type Stripe from "stripe";
import { createCheckoutMetadata } from "@/internal/metadata/metadataUtils.js";
import { isOneOff } from "@/internal/products/productUtils.js";
import { handleOneOffFunction } from "../attach/attachFunctions/addProductFlow/handleOneOffFunction.js";
import { handlePaidProduct } from "../attach/attachFunctions/addProductFlow/handlePaidProduct.js";
import { handleMultiAttachFlow } from "../attach/attachFunctions/multiAttach/handleMultiAttachFlow.js";
import {
type AttachParams,
AttachResultSchema,
} from "../cusProducts/AttachParams.js";
export const handleCreateInvoiceCheckout = async ({
req,
@@ -29,7 +26,7 @@ export const handleCreateInvoiceCheckout = async ({
req: any;
res?: any;
attachParams: AttachParams;
attachBody: AttachBody;
attachBody: AttachBodyV0;
config: AttachConfig;
branch: AttachBranch;
}) => {

View File

@@ -5,6 +5,7 @@ import {
SuccessCode,
} from "@autumn/shared";
import { subToPeriodStartEnd } from "@/external/stripe/stripeSubUtils/convertSubUtils.js";
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
import { attachToInsertParams } from "@/internal/products/productUtils.js";
import type { ExtendedRequest } from "@/utils/models/Request.js";
import { createFullCusProduct } from "../../../add-product/createFullCusProduct.js";
@@ -108,3 +109,64 @@ export const handleAddProduct = async ({
}
}
};
export const handleFreeProduct = async ({
ctx,
attachParams,
config,
}: {
ctx: AutumnContext;
attachParams: AttachParams;
config?: AttachConfig;
}) => {
const { logger } = ctx;
const { products, prices } = attachParams;
const defaultConfig: AttachConfig = getDefaultAttachConfig();
// 1. If paid product
if (prices.length < 0) {
return;
}
logger.info("Inserting free product in handleFreeProduct");
const batchInsert = [];
const { mergeSub } = await getMergeCusProduct({
attachParams,
config: config || defaultConfig,
products,
});
for (const product of products) {
const curCusProduct = attachParamsToCurCusProduct({ attachParams });
let anchorToUnix;
if (curCusProduct && config?.branch === AttachBranch.NewVersion) {
anchorToUnix = curCusProduct.created_at;
}
if (mergeSub) {
const { end } = subToPeriodStartEnd({ sub: mergeSub });
anchorToUnix = end * 1000;
}
// Expire previous product
batchInsert.push(
createFullCusProduct({
db: ctx.db,
attachParams: attachToInsertParams(attachParams, product),
billLaterOnly: true,
carryExistingUsages: config?.carryUsage || false,
anchorToUnix,
logger,
}),
);
}
await Promise.all(batchInsert);
logger.info("Successfully created full cus product");
};

View File

@@ -1,41 +1,44 @@
import { createFullCusProduct } from "@/internal/customers/add-product/createFullCusProduct.js";
import {
AttachParams,
AttachResultSchema,
} from "@/internal/customers/cusProducts/AttachParams.js";
import { attachToInsertParams } from "@/internal/products/productUtils.js";
import { ExtendedRequest, ExtendedResponse } from "@/utils/models/Request.js";
import {
AttachBody,
AttachBranch,
AttachConfig,
type AttachBodyV0,
type AttachBranch,
type AttachConfig,
AttachScenario,
CusProductStatus,
isTrialing,
SuccessCode,
} from "@autumn/shared";
import type Stripe from "stripe";
import { getLatestPeriodEnd } from "@/external/stripe/stripeSubUtils/convertSubUtils.js";
import { createFullCusProduct } from "@/internal/customers/add-product/createFullCusProduct.js";
import {
getCustomerSub,
paramsToCurSubSchedule,
} from "../../attachUtils/convertAttachParams.js";
type AttachParams,
AttachResultSchema,
} from "@/internal/customers/cusProducts/AttachParams.js";
import { CusProductService } from "@/internal/customers/cusProducts/CusProductService.js";
import { updateStripeSub2 } from "../upgradeFlow/updateStripeSub2.js";
import Stripe from "stripe";
import { createStripeSub2 } from "../addProductFlow/createStripeSub2.js";
import { getLatestPeriodEnd } from "@/external/stripe/stripeSubUtils/convertSubUtils.js";
import {
attachToInvoiceResponse,
insertInvoiceFromAttach,
} from "@/internal/invoices/invoiceUtils.js";
import { attachToInsertParams } from "@/internal/products/productUtils.js";
import type {
ExtendedRequest,
ExtendedResponse,
} from "@/utils/models/Request.js";
import {
getCustomerSub,
paramsToCurSubSchedule,
} from "../../attachUtils/convertAttachParams.js";
import { handleMultiAttachErrors } from "../../attachUtils/handleAttachErrors/handleMultiAttachErrors.js";
import { paramsToSubItems } from "../../mergeUtils/paramsToSubItems.js";
import { createStripeSub2 } from "../addProductFlow/createStripeSub2.js";
import { handleUpgradeFlowSchedule } from "../upgradeFlow/handleUpgradeFlowSchedule.js";
import { updateStripeSub2 } from "../upgradeFlow/updateStripeSub2.js";
import {
getAddAndRemoveProducts,
getProdListWithoutEntities,
} from "./getAddAndRemoveProducts.js";
import { handleUpgradeFlowSchedule } from "../upgradeFlow/handleUpgradeFlowSchedule.js";
import { isTrialing } from "@autumn/shared";
import { handleMultiAttachErrors } from "../../attachUtils/handleAttachErrors/handleMultiAttachErrors.js";
export const handleMultiAttachFlow = async ({
req,
@@ -48,7 +51,7 @@ export const handleMultiAttachFlow = async ({
req: ExtendedRequest;
res: ExtendedResponse;
attachParams: AttachParams;
attachBody: AttachBody;
attachBody: AttachBodyV0;
branch: AttachBranch;
config: AttachConfig;
}) => {

View File

@@ -2,11 +2,9 @@ import {
ApiVersion,
type AttachConfig,
AttachScenario,
ErrCode,
InternalError,
SuccessCode,
} from "@autumn/shared";
import { StatusCodes } from "http-status-codes";
import { getLatestPeriodEnd } from "@/external/stripe/stripeSubUtils/convertSubUtils.js";
import { subItemInCusProduct } from "@/external/stripe/stripeSubUtils/stripeSubItemUtils.js";
import { addProductsUpdatedWebhookTask } from "@/internal/analytics/handlers/handleProductsUpdated.js";
@@ -20,7 +18,6 @@ import {
attachToInsertParams,
isFreeProduct,
} from "@/internal/products/productUtils.js";
import RecaseError from "@/utils/errorUtils.js";
import {
attachParamsToCurCusProduct,
getCustomerSchedule,
@@ -62,18 +59,14 @@ export const handleScheduleFunction2 = async ({
});
if (!curSub) {
throw new RecaseError({
throw new InternalError({
message: `SCHEDULE FLOW, curSub is undefined`,
code: ErrCode.InvalidRequest,
statusCode: StatusCodes.BAD_REQUEST,
});
}
if (!curCusProduct) {
throw new RecaseError({
throw new InternalError({
message: `SCHEDULE FLOW, curCusProduct is undefined`,
code: ErrCode.InvalidRequest,
statusCode: StatusCodes.BAD_REQUEST,
});
}
@@ -232,6 +225,7 @@ export const handleScheduleFunction2 = async ({
} else {
res.status(200).json({
success: true,
message: `Successfully downgraded from ${curCusProduct.product.name} to ${product.name}`,
});
}
}

View File

@@ -34,6 +34,7 @@ export const handleUpdateQuantityFunction = async ({
});
const invoices: Stripe.Invoice[] = [];
for (const options of optionsToUpdate) {
const result = await handleUpdateFeatureQuantity({
req,

View File

@@ -1,11 +1,14 @@
import { type AttachConfig, ProrationBehavior } from "@autumn/shared";
import {
type AttachConfig,
ProrationBehavior,
RecaseError,
} 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 } from "@/internal/products/free-trials/freeTrialUtils.js";
import { SubService } from "@/internal/subscriptions/SubService.js";
import RecaseError from "@/utils/errorUtils.js";
import { nullish } from "@/utils/genUtils.js";
import type { ItemSet } from "@/utils/models/ItemSet.js";
import type { ExtendedRequest } from "@/utils/models/Request.js";
@@ -102,7 +105,7 @@ export const updateStripeSub2 = async ({
const { curMainProduct } = attachParamToCusProducts({ attachParams });
// 2. Create prorations for single use items
const { invoiceItems, cusEntIds } = await createUsageInvoiceItems({
const { cusEntIds } = await createUsageInvoiceItems({
db,
attachParams,
cusProduct: curMainProduct!,

View File

@@ -26,7 +26,7 @@ import {
} from "@/internal/products/productUtils.js";
import RecaseError from "@/utils/errorUtils.js";
import { notNullish, nullOrUndefined } from "@/utils/genUtils.js";
import { handleCheckout } from "./checkout/handleCheckout.js";
import type { AutumnContext } from "../../../honoUtils/HonoEnv.js";
import { handleAttach } from "./handleAttach.js";
import { handleAttachPreview } from "./handleAttachPreview/handleAttachPreview.js";
@@ -125,18 +125,18 @@ export const handlePublicAttachErrors = async ({
};
export const checkStripeConnections = async ({
req,
ctx,
attachParams,
createCus = true,
useCheckout = false,
}: {
req: any;
ctx: AutumnContext;
attachParams: AttachParams;
createCus?: boolean;
useCheckout?: boolean;
}) => {
const { org, customer, products, stripeCus, stripeCli } = attachParams;
const logger = req.logger;
const { logger, db } = ctx;
const env = customer.env;
// 2. If invoice only and no email, save email
@@ -144,7 +144,7 @@ export const checkStripeConnections = async ({
customer.email = `${customer.id}-${org.id}@invoices.useautumn.com`;
await Promise.all([
CusService.update({
db: req.db,
db,
idOrInternalId: customer.internal_id,
orgId: org.id,
env,
@@ -164,7 +164,7 @@ export const checkStripeConnections = async ({
if (createCus) {
batchProductUpdates.push(
createStripeCusIfNotExists({
db: req.db,
db,
org,
env,
customer,
@@ -176,7 +176,7 @@ export const checkStripeConnections = async ({
for (const product of products) {
batchProductUpdates.push(
checkStripeProductExists({
db: req.db,
db,
org,
env,
product,
@@ -189,23 +189,24 @@ export const checkStripeConnections = async ({
await createStripePrices({
attachParams,
useCheckout,
req,
ctx,
logger,
});
};
export const createStripePrices = async ({
ctx,
attachParams,
useCheckout,
req,
logger,
}: {
ctx: AutumnContext;
attachParams: AttachParams;
useCheckout: boolean;
req: any;
logger: any;
}) => {
const { prices, entitlements, products, org, stripeCli } = attachParams;
const { db } = ctx;
const batchPriceUpdates = [];
@@ -216,7 +217,7 @@ export const createStripePrices = async ({
batchPriceUpdates.push(
createStripePriceIFNotExist({
db: req.db,
db,
stripeCli,
price,
entitlements,
@@ -250,4 +251,4 @@ export const customerHasPm = async ({
attachRouter.post("/attach", handleAttach);
attachRouter.post("/attach/preview", handleAttachPreview);
attachRouter.post("/checkout", handleCheckout);
// attachRouter.post("/checkout", handleCheckout);

View File

@@ -1,5 +1,5 @@
import {
type AttachBody,
type AttachBodyV0,
CusProductStatus,
cusProductToProduct,
ErrCode,
@@ -11,36 +11,33 @@ import { ProductService } from "@/internal/products/ProductService.js";
import { isOneOff } from "@/internal/products/productUtils.js";
import RecaseError from "@/utils/errorUtils.js";
import { notNullish } from "@/utils/genUtils.js";
import type { ExtendedRequest } from "@/utils/models/Request.js";
import type { AutumnContext } from "../../../../../../honoUtils/HonoEnv";
import { getExistingCusProducts } from "../../../../cusProducts/cusProductUtils/getExistingCusProducts";
const getProductsForAttach = async ({
req,
ctx,
attachBody,
}: {
req: ExtendedRequest;
attachBody: AttachBody;
ctx: AutumnContext;
attachBody: AttachBodyV0;
}) => {
const {
product_id,
product_ids,
version,
products: inputProducts,
// products: inputProducts,
} = attachBody;
const products = await ProductService.listFull({
db: req.db,
orgId: req.orgId,
env: req.env,
inIds: inputProducts
? inputProducts.map((p) => p.product_id)
: product_ids || [product_id!],
db: ctx.db,
orgId: ctx.org.id,
env: ctx.env,
inIds: product_ids || [product_id!],
version,
});
if (notNullish(product_ids) && nullish(inputProducts)) {
if (notNullish(product_ids)) {
const freeTrialProds = products.filter((prod) =>
notNullish(prod.free_trial),
);
@@ -74,15 +71,15 @@ const getProductsForAttach = async ({
};
export const getCustomerAndProducts = async ({
req,
ctx,
attachBody,
}: {
req: ExtendedRequest;
attachBody: AttachBody;
ctx: AutumnContext;
attachBody: AttachBodyV0;
}) => {
const [customer, products] = await Promise.all([
getOrCreateCustomer({
ctx: req as unknown as AutumnContext,
ctx,
customerId: attachBody.customer_id,
customerData: {
...attachBody.customer_data,
@@ -96,7 +93,7 @@ export const getCustomerAndProducts = async ({
entityId: attachBody.entity_id || undefined,
entityData: attachBody.entity_data || undefined,
}),
getProductsForAttach({ req, attachBody }),
getProductsForAttach({ ctx, attachBody }),
]);
// if customer is on product v3, products[0] should just be the customer's product if version isn't explicitly passed in.
@@ -109,13 +106,11 @@ export const getCustomerAndProducts = async ({
internalEntityId: customer.entity?.internal_id,
});
// console.log(
// `Product ${product.id} (${product.version}), curSameProduct: ${curSameProduct?.product.id} (version: ${curSameProduct?.product.version})`,
// );
if (curSameProduct) {
products[i] = cusProductToProduct({ cusProduct: curSameProduct });
}
}
}
return { customer, products };
};

View File

@@ -1,5 +1,5 @@
import {
type AttachBody,
type AttachBodyV0,
type CreateFreeTrial,
cusProductToEnts,
cusProductToPrices,
@@ -16,22 +16,22 @@ import {
} from "@/internal/products/free-trials/freeTrialUtils.js";
import { handleNewProductItems } from "@/internal/products/product-items/productItemUtils/handleNewProductItems.js";
import { notNullish } from "@/utils/genUtils.js";
import type { ExtendedRequest } from "@/utils/models/Request.js";
import type { AutumnContext } from "../../../../../../honoUtils/HonoEnv.js";
import { mapOptionsList } from "../../mapOptionsList.js";
export const getPricesAndEnts = async ({
req,
ctx,
attachBody,
customer,
products,
}: {
req: ExtendedRequest;
attachBody: AttachBody;
ctx: AutumnContext;
attachBody: AttachBodyV0;
customer: FullCustomer;
products: FullProduct[];
}) => {
const { options: optionsInput, is_custom, free_trial } = attachBody;
const { features, db, org, logger } = req;
const { features, db, org, logger } = ctx;
const { curMainProduct, curSameProduct } = getExistingCusProducts({
product: products[0],

View File

@@ -1,15 +1,16 @@
import type { AttachBody } from "@autumn/shared";
import type { AttachBodyV0 } from "@autumn/shared";
import { nullish } from "@/utils/genUtils.js";
import type { ExtendedRequest } from "@/utils/models/Request.js";
import type { AutumnContext } from "../../../../../honoUtils/HonoEnv.js";
import type { ExtendedRequest } from "../../../../../utils/models/Request.js";
import type { AttachParams } from "../../../cusProducts/AttachParams.js";
import { processAttachBody } from "./processAttachBody.js";
export const getAttachParams = async ({
req,
ctx,
attachBody,
}: {
req: ExtendedRequest;
attachBody: AttachBody;
ctx: AutumnContext;
attachBody: AttachBodyV0;
}) => {
const {
customer,
@@ -23,7 +24,7 @@ export const getAttachParams = async ({
stripeVars,
rewards,
} = await processAttachBody({
req,
ctx,
attachBody,
});
@@ -51,17 +52,18 @@ export const getAttachParams = async ({
replaceables: [],
rewards,
// From req
req,
org: req.org,
req: ctx as ExtendedRequest,
org: ctx.org,
entities: customer.entities,
features: req.features,
features: ctx.features,
internalEntityId,
entityId: entityId || undefined,
cusProducts: customer.customer_products,
successUrl: attachBody.success_url,
invoiceOnly: attachBody.invoice,
productsList: attachBody.products || undefined,
// productsList: attachBody.products || undefined,
// || attachBody.invoice_only
billingAnchor: attachBody.billing_cycle_anchor,
@@ -72,7 +74,7 @@ export const getAttachParams = async ({
setupPayment: attachBody.setup_payment,
// Others
apiVersion: req.apiVersion.value,
apiVersion: ctx.apiVersion.value,
};
return {

View File

@@ -1,20 +1,20 @@
import { type AttachBody, ErrCode } from "@autumn/shared";
import { type AttachBodyV0, ErrCode } from "@autumn/shared";
import type Stripe from "stripe";
import { createStripeCli } from "@/external/connect/createStripeCli.js";
import { RewardService } from "@/internal/rewards/RewardService.js";
import RecaseError from "@/utils/errorUtils.js";
import type { ExtendedRequest } from "@/utils/models/Request.js";
import type { AutumnContext } from "../../../../../honoUtils/HonoEnv.js";
import { getCustomerAndProducts } from "./attachParamsUtils/getCusAndProducts.js";
import { getPricesAndEnts } from "./attachParamsUtils/getPricesAndEnts.js";
import { getStripeCusData } from "./attachParamsUtils/getStripeCusData.js";
export const getRewards = async ({
req,
ctx,
attachBody,
stripeCli,
}: {
req: ExtendedRequest;
attachBody: AttachBody;
ctx: AutumnContext;
attachBody: AttachBodyV0;
stripeCli: Stripe;
}) => {
const { reward: idOrCode } = attachBody;
@@ -31,10 +31,10 @@ export const getRewards = async ({
// 1. Get reward by id or promo code
const rewards = await RewardService.getByIdOrCode({
db: req.db,
db: ctx.db,
codes: rewardArray,
orgId: req.org.id,
env: req.env,
orgId: ctx.org.id,
env: ctx.env,
});
for (const reward of rewardArray) {
@@ -62,33 +62,33 @@ export const getRewards = async ({
};
export const processAttachBody = async ({
req,
ctx,
attachBody,
}: {
req: ExtendedRequest;
attachBody: AttachBody;
ctx: AutumnContext;
attachBody: AttachBodyV0;
}) => {
// 1. Get customer and products
const { org, env, logger } = req;
const { org, env, logger } = ctx;
const stripeCli = createStripeCli({ org, env });
const { customer, products } = await getCustomerAndProducts({
req,
ctx,
attachBody,
});
const [stripeCusData, rewardData] = await Promise.all([
getStripeCusData({
stripeCli,
db: req.db,
db: ctx.db,
org,
env,
customer,
logger,
}),
getRewards({
req,
ctx,
attachBody,
stripeCli,
}),
@@ -104,7 +104,7 @@ export const processAttachBody = async ({
customPrices,
customEnts,
} = await getPricesAndEnts({
req,
ctx,
attachBody,
customer,
products,

View File

@@ -1,5 +1,5 @@
import {
type AttachBody,
type AttachBodyV0,
AttachBranch,
AttachErrCode,
BillingInterval,
@@ -9,6 +9,7 @@ import {
type FeatureOptions,
type FullCusProduct,
productsAreSame,
RecaseError,
} from "@autumn/shared";
import { findPrepaidPrice } from "@/internal/products/prices/priceUtils/findPriceUtils.js";
import { hasPrepaidPrice } from "@/internal/products/prices/priceUtils/usagePriceUtils/classifyUsagePrice.js";
@@ -17,16 +18,12 @@ import {
isFreeProduct,
isProductUpgrade,
} from "@/internal/products/productUtils.js";
import RecaseError from "@/utils/errorUtils.js";
import { notNullish } from "@/utils/genUtils.js";
import type { ExtendedRequest } from "@/utils/models/Request.js";
import type { AutumnContext } from "../../../../honoUtils/HonoEnv.js";
import type { AttachParams } from "../../cusProducts/AttachParams.js";
import { getExistingCusProducts } from "../../cusProducts/cusProductUtils/getExistingCusProducts.js";
import { isMainTrialBranch } from "./attachUtils.js";
import {
attachParamToCusProducts,
getCustomerSub,
} from "./convertAttachParams.js";
import { attachParamToCusProducts } from "./convertAttachParams.js";
const handleMultiProductErrors = async ({
attachParams,
@@ -92,6 +89,8 @@ const getOptionsToUpdate = ({
}) => {
const optionsToUpdate: { new: FeatureOptions; old: FeatureOptions }[] = [];
const prices = cusProductToPrices({ cusProduct: curSameProduct });
console.log("Old options list: ", oldOptionsList);
console.log("New options list: ", newOptionsList);
for (const newOptions of newOptionsList) {
const internalFeatureId = newOptions.internal_feature_id;
@@ -291,26 +290,17 @@ const getChangeProductBranch = async ({
};
export const getAttachBranch = async ({
req,
// biome-ignore lint/correctness/noUnusedFunctionParameters: might be used in the future
ctx,
attachBody,
attachParams,
fromPreview,
}: {
req: ExtendedRequest;
attachBody: AttachBody;
ctx: AutumnContext;
attachBody: AttachBodyV0;
attachParams: AttachParams;
fromPreview?: boolean;
}) => {
if (notNullish(attachBody.products)) {
// 1.
const { subId } = await getCustomerSub({ attachParams, onlySubId: true });
if (subId) {
return AttachBranch.MultiAttachUpdate;
}
return AttachBranch.MultiAttach;
}
if (pricesOnlyOneOff(attachParams.prices)) {
return AttachBranch.OneOff;
}

View File

@@ -1,5 +1,5 @@
import {
type AttachBody,
type AttachBodyV0,
AttachBranch,
type AttachConfig,
cusProductToPrices,
@@ -10,6 +10,7 @@ import {
import { isDefaultTrialFullProduct } from "@/internal/products/productUtils/classifyProduct.js";
import { isFreeProduct } from "@/internal/products/productUtils.js";
import { notNullish, nullish } from "@/utils/genUtils.js";
import type { AutumnContext } from "../../../../honoUtils/HonoEnv.js";
import type { AttachParams } from "../../cusProducts/AttachParams.js";
import { willMergeSub } from "../mergeUtils/mergeUtils.js";
import type { AttachFlags } from "../models/AttachFlags.js";
@@ -55,20 +56,20 @@ export const intervalsAreSame = ({
};
export const getAttachConfig = async ({
req,
ctx,
attachParams,
attachBody,
branch,
}: {
req: any;
ctx: AutumnContext;
attachParams: AttachParams;
attachBody: AttachBody;
attachBody: AttachBodyV0;
branch: AttachBranch;
}) => {
const { org, prices, paymentMethod } = attachParams;
const flags: AttachFlags = {
isPublic: req.isPublic,
isPublic: ctx.isPublic,
forceCheckout: attachBody.force_checkout || false,
invoiceOnly: attachParams.invoiceOnly || false,
isFree: isFreeProduct(prices),

View File

@@ -1,5 +1,5 @@
import {
type AttachBody,
type AttachBodyV0,
AttachBranch,
type AttachConfig,
AttachFunction,
@@ -37,7 +37,7 @@ export const getAttachFunction = async ({
}: {
branch: AttachBranch;
attachParams: AttachParams;
attachBody: AttachBody;
attachBody: AttachBodyV0;
config: AttachConfig;
}) => {
const { onlyCheckout } = config;
@@ -117,7 +117,7 @@ export const runAttachFunction = async ({
res: any;
branch: AttachBranch;
attachParams: AttachParams;
attachBody: AttachBody;
attachBody: AttachBodyV0;
config: AttachConfig;
}) => {
const { logger, db } = req;
@@ -130,9 +130,6 @@ export const runAttachFunction = async ({
config,
});
// console.log("Attach Function:", attachFunction);
// throw new Error("Attach Function:");
const customer = attachParams.customer;
const org = attachParams.org;

View File

@@ -1,9 +1,10 @@
import {
type AttachBody,
type AttachBodyV0,
AttachBranch,
type AttachConfig,
BillingType,
ErrCode,
RecaseError,
type UsagePriceConfig,
} from "@autumn/shared";
import { StatusCodes } from "http-status-codes";
@@ -13,7 +14,6 @@ import {
getPriceEntitlement,
priceIsOneOffAndTiered,
} from "@/internal/products/prices/priceUtils.js";
import RecaseError from "@/utils/errorUtils.js";
import { notNullish, nullOrUndefined } from "@/utils/genUtils.js";
import type { AttachParams } from "../../cusProducts/AttachParams.js";
import type { AttachFlags } from "../models/AttachFlags.js";
@@ -129,8 +129,6 @@ const handlePrepaidErrors = async ({
) {
throw new RecaseError({
message: `Quantity + included usage exceeds usage limit of ${usageLimit} for feature ${priceEnt.feature_id}`,
code: ErrCode.InvalidOptions,
statusCode: 400,
});
}
}
@@ -151,15 +149,11 @@ export const handleCustomPaymentMethodErrors = ({
throw new RecaseError({
message:
"This customer is billed outside of Stripe, please use the origin platform to manage their billing.",
code: ErrCode.InvalidRequest,
statusCode: StatusCodes.BAD_REQUEST,
});
} else if (attachParams.customer.processors?.vercel?.installation_id) {
throw new RecaseError({
message:
"This customer is billed outside of Stripe, please use the origin platform to manage their billing.",
code: ErrCode.InvalidRequest,
statusCode: StatusCodes.BAD_REQUEST,
});
}
};
@@ -172,7 +166,7 @@ export const handleAttachErrors = async ({
config,
}: {
attachParams: AttachParams;
attachBody: AttachBody;
attachBody: AttachBodyV0;
branch: AttachBranch;
flags: AttachFlags;
config: AttachConfig;
@@ -228,8 +222,6 @@ export const handleAttachErrors = async ({
throw new RecaseError({
message:
"Not allowed to update current product when using publishable key",
code: ErrCode.InvalidRequest,
statusCode: StatusCodes.BAD_REQUEST,
});
}
}

View File

@@ -1,5 +1,5 @@
import {
type AttachBody,
type AttachBodyV0,
type AttachBranch,
isUsagePrice,
notNullish,
@@ -15,7 +15,7 @@ export const handleMultiAttachErrors = async ({
branch,
}: {
attachParams: AttachParams;
attachBody: AttachBody;
attachBody: AttachBodyV0;
branch: AttachBranch;
}) => {
const { products, prices, productsList } = attachParams;

View File

@@ -1,36 +0,0 @@
import { ExtendedRequest } from "@/utils/models/Request.js";
import { AttachParams } from "../../cusProducts/AttachParams.js";
import { AttachBranch } from "@autumn/shared";
import {
attachParamsToCurCusProduct,
attachParamToCusProducts,
} from "../attachUtils/convertAttachParams.js";
import { cusProductToPrices } from "@autumn/shared";
import { isFreeProduct } from "@/internal/products/productUtils.js";
export const getHasProrations = async ({
req,
branch,
attachParams,
}: {
req: ExtendedRequest;
branch: AttachBranch;
attachParams: AttachParams;
}) => {
let hasProrations = false;
let { curMainProduct } = attachParamToCusProducts({ attachParams });
if (branch == AttachBranch.Upgrade) {
let curPrices = cusProductToPrices({ cusProduct: curMainProduct! });
if (!isFreeProduct(curPrices)) {
return true;
}
}
if (branch == AttachBranch.UpdatePrepaidQuantity) {
return true;
}
return false;
};

View File

@@ -1,204 +0,0 @@
import {
type AttachBody,
AttachBodySchema,
AttachFunction,
type FeatureOptions,
} from "@autumn/shared";
import { priceToFeature } from "@/internal/products/prices/priceUtils/convertPrice.js";
import { isPrepaidPrice } from "@/internal/products/prices/priceUtils/usagePriceUtils/classifyUsagePrice.js";
import { getPriceOptions } from "@/internal/products/prices/priceUtils.js";
import type {
ExtendedRequest,
ExtendedResponse,
} from "@/utils/models/Request.js";
import { routeHandler } from "@/utils/routerUtils.js";
import { handleCreateCheckout } from "../../add-product/handleCreateCheckout.js";
import { handleCreateInvoiceCheckout } from "../../add-product/handleCreateInvoiceCheckout.js";
import type { AttachParams } from "../../cusProducts/AttachParams.js";
import {
checkStripeConnections,
handlePrepaidErrors,
} from "../attachRouter.js";
import { getAttachParams } from "../attachUtils/attachParams/getAttachParams.js";
import { attachParamsToProduct } from "../attachUtils/convertAttachParams.js";
import { getAttachBranch } from "../attachUtils/getAttachBranch.js";
import { getAttachConfig } from "../attachUtils/getAttachConfig.js";
import { getAttachFunction } from "../attachUtils/getAttachFunction.js";
import { handleCheckoutErrors } from "../attachUtils/handleAttachErrors/handleCheckoutErrors.js";
import { attachParamsToPreview } from "../handleAttachPreview/attachParamsToPreview.js";
import { getHasProrations } from "./getHasProrations.js";
import { previewToCheckoutRes } from "./previewToCheckoutRes.js";
const getAttachVars = async ({
req,
attachBody,
}: {
req: ExtendedRequest;
attachBody: AttachBody;
}) => {
const { attachParams } = await getAttachParams({
req,
attachBody,
});
const branch = await getAttachBranch({
req,
attachBody,
attachParams,
fromPreview: true,
});
const { flags, config } = await getAttachConfig({
req,
attachParams,
attachBody,
branch,
});
const func = await getAttachFunction({
branch,
attachParams,
attachBody,
config,
});
return {
attachParams,
flags,
branch,
config,
func,
};
};
const getCheckoutOptions = async ({
req,
attachParams,
}: {
req: ExtendedRequest;
attachParams: AttachParams;
}) => {
const product = attachParamsToProduct({ attachParams });
const prepaidPrices = product.prices.filter((p) =>
isPrepaidPrice({ price: p }),
);
const newOptions: FeatureOptions[] = structuredClone(
attachParams.optionsList,
);
for (const prepaidPrice of prepaidPrices) {
const feature = priceToFeature({
price: prepaidPrice,
features: req.features,
});
const option = getPriceOptions(prepaidPrice, attachParams.optionsList);
if (!option) {
newOptions.push({
feature_id: feature?.id ?? "",
internal_feature_id: feature?.internal_id,
quantity: 1,
});
}
}
attachParams.optionsList = newOptions;
return newOptions;
};
export const handleCheckout = (req: any, res: any) =>
routeHandler({
req,
res,
action: "attach-preview",
handler: async (req: ExtendedRequest, res: ExtendedResponse) => {
const { logger, features } = req;
const attachBody = AttachBodySchema.parse(req.body);
const { attachParams, branch, func, config } = await getAttachVars({
req,
attachBody,
});
let checkoutUrl = null;
handleCheckoutErrors({
attachParams,
branch,
});
if (func === AttachFunction.CreateCheckout) {
await checkStripeConnections({
req,
attachParams,
createCus: true,
useCheckout: true,
});
await handlePrepaidErrors({
attachParams,
config,
useCheckout: config.onlyCheckout,
});
if (config.invoiceCheckout) {
const result = await handleCreateInvoiceCheckout({
req,
attachParams,
attachBody,
branch,
config,
});
checkoutUrl = result?.invoices?.[0]?.hosted_invoice_url;
} else {
const checkout = await handleCreateCheckout({
req,
res,
attachParams,
config,
returnCheckout: true,
});
checkoutUrl = checkout?.url;
}
}
console.log(`Branch: ${branch}, Func: ${func}`);
await getCheckoutOptions({
req,
attachParams,
});
const preview = await attachParamsToPreview({
req,
attachParams,
logger,
attachBody,
withPrepaid: true,
});
const checkoutRes = await previewToCheckoutRes({
req,
attachParams,
preview,
branch,
});
// Get has prorations
const hasProrations = await getHasProrations({
req,
branch,
attachParams,
});
res.status(200).json({
...checkoutRes,
url: checkoutUrl,
has_prorations: hasProrations,
});
return;
},
});

View File

@@ -1,10 +1,11 @@
import { AttachBodySchema } from "@autumn/shared";
import { AttachBodyV0Schema } from "@autumn/shared";
import { handleAttachRaceCondition } from "@/external/redis/redisUtils.js";
import type {
ExtendedRequest,
ExtendedResponse,
} from "@/utils/models/Request.js";
import { routeHandler } from "@/utils/routerUtils.js";
import type { AutumnContext } from "../../../honoUtils/HonoEnv.js";
import { checkStripeConnections } from "./attachRouter.js";
import { getAttachParams } from "./attachUtils/attachParams/getAttachParams.js";
import { getAttachBranch } from "./attachUtils/getAttachBranch.js";
@@ -21,22 +22,29 @@ export const handleAttach = async (req: any, res: any) =>
handler: async (req: ExtendedRequest, res: ExtendedResponse) => {
await handleAttachRaceCondition({ req, res });
const attachBody = AttachBodySchema.parse(req.body);
const attachBody = AttachBodyV0Schema.parse(req.body);
const ctx = req as AutumnContext;
const { attachParams, customPrices, customEnts } = await getAttachParams({
req,
ctx,
attachBody,
});
// console.log("Options list: ", attachParams.optionsList);
// throw new Error(
// "Options list: " + JSON.stringify(attachParams.optionsList),
// );
// Handle existing product
const branch = await getAttachBranch({
req,
ctx,
attachBody,
attachParams,
});
const { flags, config } = await getAttachConfig({
req,
ctx,
attachParams,
attachBody,
branch,
@@ -51,7 +59,7 @@ export const handleAttach = async (req: any, res: any) =>
});
await checkStripeConnections({
req,
ctx,
attachParams,
useCheckout: config.onlyCheckout,
});
@@ -82,7 +90,7 @@ export const handleAttach = async (req: any, res: any) =>
freeTrial: attachParams.freeTrial,
},
});
} catch (error) {}
} catch (_error) {}
await runAttachFunction({
req,

View File

@@ -1,6 +1,7 @@
import {
type AttachBody,
type AttachBodyV0,
type AttachBranch,
type AttachConfig,
cusProductsToPrices,
type PreviewLineItem,
} from "@autumn/shared";
@@ -9,7 +10,7 @@ import type Stripe from "stripe";
import { getEarliestPeriodEnd } from "@/external/stripe/stripeSubUtils/convertSubUtils.js";
import { freeTrialToStripeTimestamp } from "@/internal/products/free-trials/freeTrialUtils.js";
import { notNullish } from "@/utils/genUtils.js";
import type { ExtendedRequest } from "@/utils/models/Request.js";
import type { AutumnContext } from "../../../../honoUtils/HonoEnv.js";
import type { AttachParams } from "../../cusProducts/AttachParams.js";
import { getAddAndRemoveProducts } from "../attachFunctions/multiAttach/getAddAndRemoveProducts.js";
import { priceToNewPreviewItem } from "../attachPreviewUtils/priceToNewPreviewItem.js";
@@ -18,18 +19,17 @@ import { getCustomerSub } from "../attachUtils/convertAttachParams.js";
import { handleMultiAttachErrors } from "../attachUtils/handleAttachErrors/handleMultiAttachErrors.js";
export const getMultiAttachPreview = async ({
req,
// biome-ignore lint/correctness/noUnusedFunctionParameters: might be used in the future
ctx,
attachBody,
attachParams,
logger,
config,
branch,
}: {
req: ExtendedRequest;
attachBody: AttachBody;
ctx: AutumnContext;
attachBody: AttachBodyV0;
attachParams: AttachParams;
logger: any;
config: any;
config: AttachConfig;
branch: AttachBranch;
}) => {
await handleMultiAttachErrors({ attachParams, attachBody, branch });

View File

@@ -12,6 +12,7 @@ import { getNextStartOfMonthUnix } from "@/internal/products/prices/billingInter
import { getAlignedUnix } from "@/internal/products/prices/billingIntervalUtils2.js";
import { getLargestInterval } from "@/internal/products/prices/priceUtils/priceIntervalUtils.js";
import { mapToProductItems } from "@/internal/products/productV2Utils.js";
import type { Logger } from "../../../../external/logtail/logtailUtils.js";
import type { AttachParams } from "../../cusProducts/AttachParams.js";
import {
attachParamsToProduct,
@@ -86,7 +87,7 @@ export const getNewProductPreview = async ({
}: {
branch: AttachBranch;
attachParams: AttachParams;
logger: any;
logger: Logger;
config: AttachConfig;
withPrepaid?: boolean;
}) => {

View File

@@ -25,7 +25,7 @@ import { isPrepaidPrice } from "@/internal/products/prices/priceUtils/usagePrice
import { isFreeProduct } from "@/internal/products/productUtils.js";
import { mapToProductItems } from "@/internal/products/productV2Utils.js";
import { nullish } from "@/utils/genUtils.js";
import type { ExtendedRequest } from "@/utils/models/Request.js";
import type { AutumnContext } from "../../../../honoUtils/HonoEnv.js";
import type { AttachParams } from "../../cusProducts/AttachParams.js";
import {
attachParamsToProduct,
@@ -132,21 +132,21 @@ const filterNoProratePrepaidItems = ({
};
export const getUpgradeProductPreview = async ({
req,
ctx,
attachParams,
branch,
now,
withPrepaid = false,
config,
}: {
req: ExtendedRequest;
ctx: AutumnContext;
attachParams: AttachParams;
branch: AttachBranch;
now: number;
withPrepaid?: boolean;
config: AttachConfig;
}) => {
const { logger } = req;
const { logger } = ctx;
const { curMainProduct, curSameProduct } = attachParamToCusProducts({
attachParams,

View File

@@ -1,33 +1,32 @@
import { AttachBodySchema } from "@autumn/shared";
import { AttachBodyV0Schema } from "@autumn/shared";
import type {
ExtendedRequest,
ExtendedResponse,
} from "@/utils/models/Request.js";
import { routeHandler } from "@/utils/routerUtils.js";
import type { AutumnContext } from "../../../../honoUtils/HonoEnv.js";
import { attachParamsToPreview } from "../../../billing/attachPreview/attachParamsToPreview.js";
import { getAttachParams } from "../attachUtils/attachParams/getAttachParams.js";
import { attachParamsToPreview } from "./attachParamsToPreview.js";
export const handleAttachPreview = (req: any, res: any) =>
routeHandler({
req,
res,
action: "attach-preview",
handler: async (req: ExtendedRequest, res: ExtendedResponse) => {
const { logger } = req;
const attachBody = AttachBodySchema.parse(req.body);
const attachBody = AttachBodyV0Schema.parse(req.body);
// console.log("attachBody", attachBody);
const ctx = req as AutumnContext;
const { attachParams } = await getAttachParams({
req,
ctx,
attachBody,
});
const attachPreview = await attachParamsToPreview({
req,
ctx,
attachParams,
attachBody,
logger,
});
res.status(200).json(attachPreview);

View File

@@ -4,16 +4,14 @@ import {
cusProductToPrices,
cusProductToProduct,
type EntitlementWithFeature,
ErrCode,
type FullCusProduct,
type FullCustomer,
type Price,
ProrationBehavior,
RecaseError,
} from "@autumn/shared";
import { StatusCodes } from "http-status-codes";
import { createStripeCli } from "@/external/connect/createStripeCli.js";
import { isFreeProduct, isOneOff } from "@/internal/products/productUtils.js";
import RecaseError from "@/utils/errorUtils.js";
import type { ExtendedRequest } from "@/utils/models/Request.js";
import { handleRenewProduct } from "../attach/attachFunctions/handleRenewProduct.js";
import { handleScheduleFunction2 } from "../attach/attachFunctions/scheduleFlow/handleScheduleFlow2.js";
@@ -95,13 +93,13 @@ export const handleCancelProduct = async ({
// 2. If there's a scheduled product, throw error?
const isMain = !cusProduct.product.is_add_on;
const product = cusProductToProduct({ cusProduct });
const isFree = isFreeProduct(product.prices || []);
if (isMain) {
if (cusProduct.canceled && !expireImmediately) {
throw new RecaseError({
message: `Product ${cusProduct.product.name} is already about to cancel at the end of cycle.`,
code: ErrCode.InvalidRequest,
statusCode: StatusCodes.BAD_REQUEST,
});
}
@@ -111,15 +109,12 @@ export const handleCancelProduct = async ({
) {
throw new RecaseError({
message: `Please delete scheduled product ${curScheduledProduct.product.name} first`,
code: ErrCode.InvalidRequest,
statusCode: StatusCodes.BAD_REQUEST,
});
}
}
// 2. If expire at cycle end, just cancel subscriptions
if (!expireImmediately) {
const product = cusProductToProduct({ cusProduct });
if (!expireImmediately && !isFree) {
const defaultProduct = await getDefaultProduct({
req,
productGroup: product.group,
@@ -170,7 +165,6 @@ export const handleCancelProduct = async ({
}
// Cancel product immediately
const product = cusProductToProduct({ cusProduct });
await handleUpgradeFlow({
req,
res: null,

View File

@@ -53,8 +53,6 @@ export const initNextResetAt = ({
// "Trial end timestamp: ",
// formatUnixToDateTime(trialEndTimestamp! * 1000),
// );
// console.log("Should apply trial: ", shouldApplyTrial);
// console.log("Anchor to unix: ", formatUnixToDateTime(anchorToUnix!));
if (freeTrial && shouldApplyTrial && trialEndTimestamp) {
nextResetAtCalculated = new UTCDate(trialEndTimestamp! * 1000);

View File

@@ -1,217 +1,217 @@
import type { Redis } from "ioredis";
import { executeBatchDeduction } from "./executeBatchDeduction.js";
// import type { Redis } from "ioredis";
// import { executeBatchDeduction } from "./executeBatchDeduction.js";
interface BatchRequest {
amount: number;
timestamp: number;
properties: Record<string, any>;
resolve: (result: { success: boolean; error?: string }) => void;
reject: (error: Error) => void;
}
// interface BatchRequest {
// amount: number;
// timestamp: number;
// properties: Record<string, any>;
// resolve: (result: { success: boolean; error?: string }) => void;
// reject: (error: Error) => void;
// }
export interface BatchContext {
customerId: string;
featureId: string;
orgId: string;
orgSlug: string;
env: string;
entityId?: string;
}
// export interface BatchContext {
// customerId: string;
// featureId: string;
// orgId: string;
// orgSlug: string;
// env: string;
// entityId?: string;
// }
interface Batch {
requests: BatchRequest[];
timer: NodeJS.Timeout | null;
context?: BatchContext;
}
// interface Batch {
// requests: BatchRequest[];
// timer: NodeJS.Timeout | null;
// context?: BatchContext;
// }
/**
* Batching manager for Redis track deductions
* Collects multiple deduction requests within a time window and processes them atomically in a single Lua script
*
* Benefits:
* - Massive performance improvements for high-concurrency scenarios
* - Atomic deductions across multiple requests
* - Reduced Redis round trips
*/
export class BatchingManager {
private batches = new Map<string, Batch>();
private readonly BATCH_WINDOW_MS = 10; // 10ms batching window
private readonly MAX_BATCH_SIZE = 100000; // Handle up to 100k concurrent requests
// /**
// * Batching manager for Redis track deductions
// * Collects multiple deduction requests within a time window and processes them atomically in a single Lua script
// *
// * Benefits:
// * - Massive performance improvements for high-concurrency scenarios
// * - Atomic deductions across multiple requests
// * - Reduced Redis round trips
// */
// export class BatchingManager {
// private batches = new Map<string, Batch>();
// private readonly BATCH_WINDOW_MS = 10; // 10ms batching window
// private readonly MAX_BATCH_SIZE = 100000; // Handle up to 100k concurrent requests
/**
* Request a deduction with automatic batching
* Returns a promise that resolves when the batch is processed
*/
async deduct({
redis,
cacheKey,
featureId,
amount,
timestamp,
properties,
context,
}: {
redis: Redis;
cacheKey: string;
featureId: string;
amount: number;
timestamp: number;
properties: Record<string, any>;
context: BatchContext;
}): Promise<{ success: boolean; error?: string }> {
const batchKey = `${cacheKey}:${featureId}`;
// /**
// * Request a deduction with automatic batching
// * Returns a promise that resolves when the batch is processed
// */
// async deduct({
// redis,
// cacheKey,
// featureId,
// amount,
// timestamp,
// properties,
// context,
// }: {
// redis: Redis;
// cacheKey: string;
// featureId: string;
// amount: number;
// timestamp: number;
// properties: Record<string, any>;
// context: BatchContext;
// }): Promise<{ success: boolean; error?: string }> {
// const batchKey = `${cacheKey}:${featureId}`;
return new Promise((resolve, reject) => {
// Create batch if it doesn't exist
if (!this.batches.has(batchKey)) {
this.batches.set(batchKey, {
requests: [],
timer: null,
context,
});
// return new Promise((resolve, reject) => {
// // Create batch if it doesn't exist
// if (!this.batches.has(batchKey)) {
// this.batches.set(batchKey, {
// requests: [],
// timer: null,
// context,
// });
// Schedule batch execution
this.scheduleBatch(batchKey, redis, cacheKey, featureId);
}
// // Schedule batch execution
// this.scheduleBatch(batchKey, redis, cacheKey, featureId);
// }
const batch = this.batches.get(batchKey);
if (!batch) {
reject(new Error("Failed to get batch"));
return;
}
// const batch = this.batches.get(batchKey);
// if (!batch) {
// reject(new Error("Failed to get batch"));
// return;
// }
// Add request to batch
batch.requests.push({
amount,
timestamp,
properties,
resolve,
reject,
});
// // Add request to batch
// batch.requests.push({
// amount,
// timestamp,
// properties,
// resolve,
// reject,
// });
// Force flush if batch is full
if (batch.requests.length >= this.MAX_BATCH_SIZE) {
this.executeBatch(batchKey, redis, cacheKey, featureId);
}
});
}
// // Force flush if batch is full
// if (batch.requests.length >= this.MAX_BATCH_SIZE) {
// this.executeBatch(batchKey, redis, cacheKey, featureId);
// }
// });
// }
/**
* Schedule batch execution after window expires
*/
private scheduleBatch(
batchKey: string,
redis: Redis,
cacheKey: string,
featureId: string,
): void {
const batch = this.batches.get(batchKey);
if (!batch) return;
// /**
// * Schedule batch execution after window expires
// */
// private scheduleBatch(
// batchKey: string,
// redis: Redis,
// cacheKey: string,
// featureId: string,
// ): void {
// const batch = this.batches.get(batchKey);
// if (!batch) return;
batch.timer = setTimeout(() => {
this.executeBatch(batchKey, redis, cacheKey, featureId);
}, this.BATCH_WINDOW_MS);
}
// batch.timer = setTimeout(() => {
// this.executeBatch(batchKey, redis, cacheKey, featureId);
// }, this.BATCH_WINDOW_MS);
// }
/**
* Execute the batch - process all requests in one Lua script
*/
private async executeBatch(
batchKey: string,
redis: Redis,
cacheKey: string,
featureId: string,
): Promise<void> {
// CRITICAL: Remove batch from map FIRST to prevent race condition
// New requests will create a new batch instead of adding to this one
const batch = this.batches.get(batchKey);
if (!batch || batch.requests.length === 0) {
return;
}
// /**
// * Execute the batch - process all requests in one Lua script
// */
// private async executeBatch(
// batchKey: string,
// redis: Redis,
// cacheKey: string,
// featureId: string,
// ): Promise<void> {
// // CRITICAL: Remove batch from map FIRST to prevent race condition
// // New requests will create a new batch instead of adding to this one
// const batch = this.batches.get(batchKey);
// if (!batch || batch.requests.length === 0) {
// return;
// }
// Clear timer and remove from map IMMEDIATELY
if (batch.timer) {
clearTimeout(batch.timer);
batch.timer = null;
}
this.batches.delete(batchKey);
// // Clear timer and remove from map IMMEDIATELY
// if (batch.timer) {
// clearTimeout(batch.timer);
// batch.timer = null;
// }
// this.batches.delete(batchKey);
const requests = batch.requests;
const amounts = requests.map((r) => r.amount);
const batchSize = requests.length;
// const requests = batch.requests;
// const amounts = requests.map((r) => r.amount);
// const batchSize = requests.length;
console.log(
`🚀 Executing batch with ${batchSize} requests for feature ${featureId}`,
);
// console.log(
// `🚀 Executing batch with ${batchSize} requests for feature ${featureId}`,
// );
try {
// Execute batch Lua script
const result = await executeBatchDeduction({
redis,
cacheKey,
targetFeatureId: featureId,
amounts,
});
// try {
// // Execute batch Lua script
// const result = await executeBatchDeduction({
// redis,
// cacheKey,
// targetFeatureId: featureId,
// amounts,
// });
console.log(
`✅ Batch completed (${batchSize} requests, ${result.successCount} succeeded)`,
);
// console.log(
// `✅ Batch completed (${batchSize} requests, ${result.successCount} succeeded)`,
// );
// Resolve each request based on success/fail counts
if (result.success) {
const successCount = result.successCount || 0;
// // Resolve each request based on success/fail counts
// if (result.success) {
// const successCount = result.successCount || 0;
// TODO: Queue Postgres sync job for successful deductions if needed
// This can be added later when integrating with the sync system
// // TODO: Queue Postgres sync job for successful deductions if needed
// // This can be added later when integrating with the sync system
// First N requests succeed, rest fail
for (let i = 0; i < requests.length; i++) {
requests[i].resolve({
success: i < successCount,
error:
i < successCount
? undefined
: result.error || "INSUFFICIENT_BALANCE",
});
}
} else {
// Batch failed entirely (e.g., customer not found)
for (const request of requests) {
request.resolve({
success: false,
error: result.error || "BATCH_FAILED",
});
}
}
} catch (error) {
console.error(`❌ Batch execution error:`, error);
// Reject all requests on error
for (const request of requests) {
request.reject(
error instanceof Error ? error : new Error(String(error)),
);
}
}
}
// // First N requests succeed, rest fail
// for (let i = 0; i < requests.length; i++) {
// requests[i].resolve({
// success: i < successCount,
// error:
// i < successCount
// ? undefined
// : result.error || "INSUFFICIENT_BALANCE",
// });
// }
// } else {
// // Batch failed entirely (e.g., customer not found)
// for (const request of requests) {
// request.resolve({
// success: false,
// error: result.error || "BATCH_FAILED",
// });
// }
// }
// } catch (error) {
// console.error(`❌ Batch execution error:`, error);
// // Reject all requests on error
// for (const request of requests) {
// request.reject(
// error instanceof Error ? error : new Error(String(error)),
// );
// }
// }
// }
/**
* Get current batch statistics (for monitoring)
*/
getStats(): {
activeBatches: number;
totalPendingRequests: number;
} {
let totalPendingRequests = 0;
for (const batch of this.batches.values()) {
totalPendingRequests += batch.requests.length;
}
// /**
// * Get current batch statistics (for monitoring)
// */
// getStats(): {
// activeBatches: number;
// totalPendingRequests: number;
// } {
// let totalPendingRequests = 0;
// for (const batch of this.batches.values()) {
// totalPendingRequests += batch.requests.length;
// }
return {
activeBatches: this.batches.size,
totalPendingRequests,
};
}
}
// return {
// activeBatches: this.batches.size,
// totalPendingRequests,
// };
// }
// }
// Singleton instance
export const globalBatchingManager = new BatchingManager();
// // Singleton instance
// export const globalBatchingManager = new BatchingManager();

View File

@@ -0,0 +1,75 @@
import * as Sentry from "@sentry/bun";
import { redis } from "@/external/redis/initRedis.js";
/**
* Batch delete multiple customer caches in one Redis operation
* Much more efficient than calling deleteCachedApiCustomer multiple times
* @param customers Array of {orgId, env, customerId} to delete
* @returns Number of keys deleted
*/
export const batchDeleteCachedCustomers = async ({
customers,
}: {
customers: Array<{
orgId: string;
env: string;
customerId: string;
}>;
}): Promise<number> => {
if (redis.status !== "ready") {
console.warn("❗️ Redis not ready, skipping batch cache deletion", {
status: redis.status,
count: customers.length,
});
return 0;
}
if (customers.length === 0) {
return 0;
}
try {
// Group customers by orgId to avoid Redis Cluster hash slot errors
// All keys in a Lua script must be in the same hash slot (same {orgId})
const customersByOrg = new Map<string, typeof customers>();
for (const customer of customers) {
const key = customer.orgId;
if (!customersByOrg.has(key)) {
customersByOrg.set(key, []);
}
customersByOrg.get(key)?.push(customer);
}
// Use pipeline to batch all org deletions into one network round trip
const pipeline = redis.pipeline();
for (const orgCustomers of customersByOrg.values()) {
pipeline.batchDeleteCustomers(JSON.stringify(orgCustomers));
}
const results = await pipeline.exec();
// Sum up all deleted counts
let totalDeleted = 0;
if (results) {
for (const [error, result] of results) {
if (error) {
console.error("Error in pipeline batch delete:", error);
throw error;
}
totalDeleted += result as number;
}
}
console.log(
`Batch deleted ${totalDeleted} cache keys for ${customers.length} customers across ${customersByOrg.size} orgs`,
);
return totalDeleted;
} catch (error) {
console.error("Error batch deleting customers:", error);
Sentry.captureException(error);
throw error;
}
};

View File

@@ -1,4 +1,3 @@
import { DELETE_CUSTOMER_SCRIPT } from "@lua/luaScripts.js";
import { redis } from "@/external/redis/initRedis.js";
import { logger } from "../../../../external/logtail/logtailUtils.js";
@@ -29,13 +28,7 @@ export const deleteCachedApiCustomer = async ({
if (!customerId) return;
try {
const deletedCount = await redis.eval(
DELETE_CUSTOMER_SCRIPT,
0, // No KEYS, all params in ARGV
orgId,
env,
customerId,
);
const deletedCount = await redis.deleteCustomer(orgId, env, customerId);
logger.info(
`Deleted ${deletedCount} cache keys for customer ${customerId}, source: ${source}`,

View File

@@ -1,47 +1,44 @@
import { BATCH_DEDUCTION_SCRIPT } from "@lua/luaScripts.js";
import type { Redis } from "ioredis";
interface BatchDeductionResult {
success: boolean;
successCount: number;
error?: string;
}
/**
* Execute batch deduction Lua script
* Processes multiple deductions atomically in a single Redis call
* Supports credit system features as alternative payment sources
*/
export const executeBatchDeduction = async ({
redis,
cacheKey,
targetFeatureId,
amounts,
}: {
redis: Redis;
cacheKey: string;
targetFeatureId: string; // The feature we're trying to deduct from
amounts: number[];
}): Promise<BatchDeductionResult> => {
try {
// Execute Lua script
const result = await redis.eval(
BATCH_DEDUCTION_SCRIPT,
2, // number of keys
cacheKey, // KEYS[1]
targetFeatureId, // KEYS[2] - target feature ID
JSON.stringify(amounts), // ARGV[1]
);
// /**
// * Execute batch deduction Lua script
// * Processes multiple deductions atomically in a single Redis call
// * Supports credit system features as alternative payment sources
// */
// export const executeBatchDeduction = async ({
// redis,
// cacheKey,
// targetFeatureId,
// amounts,
// }: {
// redis: Redis;
// cacheKey: string;
// targetFeatureId: string; // The feature we're trying to deduct from
// amounts: number[];
// }): Promise<BatchDeductionResult> => {
// try {
// // Execute Lua script
// const result = await redis.eval(
// BATCH_DEDUCTION_SCRIPT,
// 2, // number of keys
// cacheKey, // KEYS[1]
// targetFeatureId, // KEYS[2] - target feature ID
// JSON.stringify(amounts), // ARGV[1]
// );
// Parse result
const parsed = JSON.parse(result as string) as BatchDeductionResult;
return parsed;
} catch (error) {
console.error("Error executing batch deduction:", error);
return {
success: false,
successCount: 0,
error: error instanceof Error ? error.message : "UNKNOWN_ERROR",
};
}
};
// // Parse result
// const parsed = JSON.parse(result as string) as BatchDeductionResult;
// return parsed;
// } catch (error) {
// console.error("Error executing batch deduction:", error);
// return {
// success: false,
// successCount: 0,
// error: error instanceof Error ? error.message : "UNKNOWN_ERROR",
// };
// }
// };

View File

@@ -1,4 +1,5 @@
import {
ApiBaseEntitySchema,
type ApiCustomer,
ApiCustomerSchema,
type AppEnv,
@@ -10,7 +11,6 @@ import {
filterPlanAndFeatureExpand,
} from "@autumn/shared";
import { CACHE_CUSTOMER_VERSION } from "@lua/cacheConfig.js";
import { GET_CUSTOMER_SCRIPT } from "@lua/luaScripts.js";
import { redis } from "../../../../external/redis/initRedis.js";
import type { AutumnContext } from "../../../../honoUtils/HonoEnv.js";
import { tryRedisRead } from "../../../../utils/cacheUtils/cacheUtils.js";
@@ -56,9 +56,7 @@ export const getCachedApiCustomer = async ({
// Try to get from cache using Lua script (unless skipCache is true)
if (!skipCache) {
const cachedResult = await tryRedisRead(() =>
redis.eval(
GET_CUSTOMER_SCRIPT,
0, // No KEYS, all params in ARGV
redis.getCustomer(
org.id,
env,
customerId,
@@ -108,7 +106,7 @@ export const getCachedApiCustomer = async ({
// Build ApiCustomer (base only, no expand) to return
const ctxWithExpand = addToExpand({
ctx,
add: [CusExpand.Invoices],
add: [CusExpand.Invoices, CusExpand.Entities],
});
const { apiCustomer, legacyData } = await getApiCustomerBase({
ctx: ctxWithExpand,
@@ -116,6 +114,16 @@ export const getCachedApiCustomer = async ({
withAutumnId: true,
});
try {
apiCustomer.entities = fullCus.entities.map((e) =>
ApiBaseEntitySchema.parse(e),
);
} catch (error) {
ctx.logger.error(
`[getCachedApiCustomer] Error parsing entities: ${error}`,
);
}
const { apiCustomer: masterApiCustomer } = await getApiCustomerBase({
ctx,
fullCus: {

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