merge dev
This commit is contained in:
113
.github/workflows/build.yml
vendored
113
.github/workflows/build.yml
vendored
@@ -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
25
docker/Dockerfile
Normal 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
37
firelens.conf
Normal 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
|
||||
@@ -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' \
|
||||
|
||||
@@ -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 \
|
||||
|
||||
@@ -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' \
|
||||
@@ -21,8 +21,8 @@
|
||||
"recommended": true,
|
||||
"suspicious": {
|
||||
"noExportsInTest": "off",
|
||||
"noExplicitAny": "off",
|
||||
"noImplicitAnyLet": "off"
|
||||
"noExplicitAny": "on",
|
||||
"noImplicitAnyLet": "on"
|
||||
},
|
||||
"complexity": {
|
||||
"noStaticOnlyClass": "off"
|
||||
|
||||
17
server/experiments/batchDelete.ts
Normal file
17
server/experiments/batchDelete.ts
Normal 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);
|
||||
@@ -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",
|
||||
|
||||
103
server/src/_luaScripts/cusLuaScripts/batchDeleteCustomers.lua
Normal file
103
server/src/_luaScripts/cusLuaScripts/batchDeleteCustomers.lua
Normal 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
// ============================================================================
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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()]);
|
||||
};
|
||||
|
||||
|
||||
@@ -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 });
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
6
server/src/external/autumn/autumnCli.ts
vendored
6
server/src/external/autumn/autumnCli.ts
vendored
@@ -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;
|
||||
};
|
||||
|
||||
6
server/src/external/autumn/autumnCliV2.ts
vendored
6
server/src/external/autumn/autumnCliV2.ts
vendored
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
144
server/src/external/redis/initRedis.ts
vendored
144
server/src/external/redis/initRedis.ts
vendored
@@ -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}`);
|
||||
|
||||
3
server/src/external/redis/redisUtils.ts
vendored
3
server/src/external/redis/redisUtils.ts
vendored
@@ -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 ({
|
||||
|
||||
35
server/src/external/sentry/sentryUtils.ts
vendored
35
server/src/external/sentry/sentryUtils.ts
vendored
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
@@ -163,7 +163,7 @@ export const handleUpsertInstallation = createRoute({
|
||||
}),
|
||||
orgCurrency: ctx.org.default_currency ?? "usd",
|
||||
})
|
||||
: null,
|
||||
: undefined,
|
||||
};
|
||||
|
||||
return c.json(installation, 200);
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) 🍂🍂🍂");
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
98
server/src/internal/api/check/runCheckWithTrack.ts
Normal file
98
server/src/internal/api/check/runCheckWithTrack.ts
Normal 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;
|
||||
};
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
// },
|
||||
// });
|
||||
// },
|
||||
// });
|
||||
|
||||
@@ -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";
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)}`,
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
93
server/src/internal/billing/attach/handleAttachV2.ts
Normal file
93
server/src/internal/billing/attach/handleAttachV2.ts
Normal 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!",
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
@@ -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);
|
||||
|
||||
27
server/src/internal/billing/checkout/getHasProrations.ts
Normal file
27
server/src/internal/billing/checkout/getHasProrations.ts
Normal 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;
|
||||
};
|
||||
205
server/src/internal/billing/checkout/handleCheckout.ts
Normal file
205
server/src/internal/billing/checkout/handleCheckout.ts
Normal 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;
|
||||
// },
|
||||
// });
|
||||
112
server/src/internal/billing/checkout/handleCheckoutV2.ts
Normal file
112
server/src/internal/billing/checkout/handleCheckoutV2.ts
Normal 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,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}) => {
|
||||
|
||||
@@ -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");
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}) => {
|
||||
|
||||
@@ -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}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ export const handleUpdateQuantityFunction = async ({
|
||||
});
|
||||
|
||||
const invoices: Stripe.Invoice[] = [];
|
||||
|
||||
for (const options of optionsToUpdate) {
|
||||
const result = await handleUpdateFeatureQuantity({
|
||||
req,
|
||||
|
||||
@@ -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!,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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;
|
||||
}) => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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",
|
||||
// };
|
||||
// }
|
||||
// };
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user