fix: deducting from purchased balance when value < 0

This commit is contained in:
John Yeo
2025-11-17 23:08:50 +00:00
parent 65fc1e882f
commit 449c5b830e
6 changed files with 134 additions and 73 deletions

View File

@@ -17,6 +17,9 @@ elif [[ "$filename" == *"/tests/"* ]]; then
elif [[ "$filename" == *".sh"* ]]; then
"$filename"
elif [[ "$filename" == *"/scripts/"* ]]; then
# Run scripts with infisical prod environment
infisical run --env=prod -- bun "$filename"
else
# NODE_ENV=development npx tsx $filename
NODE_ENV=development bun "$filename"

View File

@@ -312,11 +312,13 @@ local function deductFromCurrentBalance(cusFeature, amount, adjustGrantedBalance
}
end
-- Deduct from overage (second pass - increments purchased_balance up to max_purchase)
-- Deduct from overage (handles purchased_balance adjustments)
-- For positive amounts: increments purchased_balance up to max_purchase
-- For negative amounts (refunds): decrements purchased_balance down to 0
-- Only applies if overage_allowed is true
-- Parameters:
-- cusFeature: Balance object to deduct from
-- amount: Remaining amount to cover with overage
-- amount: Amount to handle (positive for deduction, negative for refund)
-- adjustGrantedBalance: If true, decrement granted_balance instead of incrementing usage
-- Returns: { remaining: number, deltas: [{key, field, delta}], stateChanges: [{type, index, field, newValue/delta}] }
local function deductFromOverage(cusFeature, amount, adjustGrantedBalance)
@@ -333,8 +335,8 @@ local function deductFromOverage(cusFeature, amount, adjustGrantedBalance)
}
end
-- Only proceed if there's remaining amount and overage is allowed
if remaining <= 0 then
-- Early return if no amount to process
if remaining == 0 then
return {
remaining = remaining,
deltas = deltas,
@@ -354,8 +356,10 @@ local function deductFromOverage(cusFeature, amount, adjustGrantedBalance)
}
end
-- If cusFeature has breakdowns, deduct from breakdown overage
if cusFeature.breakdown and #cusFeature.breakdown > 0 then
-- POSITIVE AMOUNT: Increment purchased_balance up to max_purchase
if remaining > 0 then
-- If cusFeature has breakdowns, deduct from breakdown overage
if cusFeature.breakdown and #cusFeature.breakdown > 0 then
for index, breakdown in ipairs(cusFeature.breakdown) do
if remaining <= 0 then break end
@@ -484,6 +488,49 @@ local function deductFromOverage(cusFeature, amount, adjustGrantedBalance)
remaining = remaining - toIncrement
end
end
-- NEGATIVE AMOUNT (REFUND): Decrement purchased_balance down to 0
else
-- If cusFeature has breakdowns, refund from breakdown overage
if cusFeature.breakdown and #cusFeature.breakdown > 0 then
for index, breakdown in ipairs(cusFeature.breakdown) do
if remaining >= 0 then break end
local breakdownAllowOverage = breakdown.overage_allowed == true
if breakdownAllowOverage then
local breakdownPurchasedBalance = breakdown.purchased_balance or 0
local toDecrement = math.min(-remaining, breakdownPurchasedBalance)
if toDecrement > 0 then
table.insert(deltas, {key = breakdown._key, field = "purchased_balance", delta = -toDecrement})
table.insert(deltas, {key = cusFeature._key, field = "purchased_balance", delta = -toDecrement})
table.insert(deltas, {key = breakdown._key, field = "usage", delta = -toDecrement})
table.insert(deltas, {key = cusFeature._key, field = "usage", delta = -toDecrement})
table.insert(stateChanges, {type = "breakdown", index = index, field = "purchased_balance", delta = -toDecrement})
table.insert(stateChanges, {type = "breakdown", index = index, field = "usage", delta = -toDecrement})
table.insert(stateChanges, {type = "cusFeature", field = "purchased_balance", delta = -toDecrement})
table.insert(stateChanges, {type = "cusFeature", field = "usage", delta = -toDecrement})
remaining = remaining + toDecrement
end
end
end
else
-- No breakdowns: refund from top-level overage
local topLevelPurchasedBalance = cusFeature.purchased_balance or 0
local toDecrement = math.min(-remaining, topLevelPurchasedBalance)
if toDecrement > 0 then
table.insert(deltas, {key = cusFeature._key, field = "purchased_balance", delta = -toDecrement})
table.insert(deltas, {key = cusFeature._key, field = "usage", delta = -toDecrement})
table.insert(stateChanges, {type = "cusFeature", field = "purchased_balance", delta = -toDecrement})
table.insert(stateChanges, {type = "cusFeature", field = "usage", delta = -toDecrement})
remaining = remaining + toDecrement
end
end
end
return {
@@ -495,6 +542,8 @@ end
-- Deduct from main balance (handles both breakdown and non-breakdown scenarios)
-- Handles both positive (deduct) and negative (refund) amounts
-- For positive amounts: deducts from current_balance, then overage
-- For negative amounts: refunds from overage (purchased_balance), then current_balance
-- Parameters:
-- cusFeature: Balance object to deduct from
-- amount: Amount to deduct (can be negative for refunds)
@@ -505,30 +554,58 @@ local function deductFromMainBalance(cusFeature, amount, adjustGrantedBalance)
local allStateChanges = {}
local remaining = amount
-- Pass 1: Deduct from current_balance (only deducts from positive balances)
local currentBalanceResult = deductFromCurrentBalance(cusFeature, remaining, adjustGrantedBalance)
remaining = currentBalanceResult.remaining
-- POSITIVE AMOUNT (DEDUCTION): current_balance → overage
local isPaidAllocated = cusFeature.feature and cusFeature.feature.type == "metered" and cusFeature.feature.consumable == false and cusFeature.overage_allowed == true
-- Collect current balance deltas and state changes
for _, delta in ipairs(currentBalanceResult.deltas) do
table.insert(allDeltas, delta)
end
for _, stateChange in ipairs(currentBalanceResult.stateChanges) do
table.insert(allStateChanges, stateChange)
end
-- Pass 2: Deduct from overage (increments purchased_balance up to max_purchase)
if remaining > 0 then
if remaining > 0 or isPaidAllocated then
-- Pass 1: Deduct from current_balance
local currentBalanceResult = deductFromCurrentBalance(cusFeature, remaining, adjustGrantedBalance)
remaining = currentBalanceResult.remaining
for _, delta in ipairs(currentBalanceResult.deltas) do
table.insert(allDeltas, delta)
end
for _, stateChange in ipairs(currentBalanceResult.stateChanges) do
table.insert(allStateChanges, stateChange)
end
-- Pass 2: Deduct from overage (increments purchased_balance up to max_purchase)
if remaining > 0 then
local overageResult = deductFromOverage(cusFeature, remaining, adjustGrantedBalance)
remaining = overageResult.remaining
for _, delta in ipairs(overageResult.deltas) do
table.insert(allDeltas, delta)
end
for _, stateChange in ipairs(overageResult.stateChanges) do
table.insert(allStateChanges, stateChange)
end
end
-- NEGATIVE AMOUNT (REFUND): overage → current_balance
else
-- Pass 1: Refund from overage (decrements purchased_balance down to 0)
local overageResult = deductFromOverage(cusFeature, remaining, adjustGrantedBalance)
remaining = overageResult.remaining
-- Collect overage deltas and state changes
for _, delta in ipairs(overageResult.deltas) do
table.insert(allDeltas, delta)
end
for _, stateChange in ipairs(overageResult.stateChanges) do
table.insert(allStateChanges, stateChange)
end
-- Pass 2: Refund to current_balance (increments current_balance)
if remaining < 0 then
local currentBalanceResult = deductFromCurrentBalance(cusFeature, remaining, adjustGrantedBalance)
remaining = currentBalanceResult.remaining
for _, delta in ipairs(currentBalanceResult.deltas) do
table.insert(allDeltas, delta)
end
for _, stateChange in ipairs(currentBalanceResult.stateChanges) do
table.insert(allStateChanges, stateChange)
end
end
end
return {
@@ -1059,19 +1136,19 @@ for _, request in ipairs(requests) do
end
end
-- Helper function to apply legacy continuous_use logic
-- Legacy case: continuous_use features always allow overage
local function applyContinuousUseLegacy(balance)
if balance.feature and balance.feature.type == "metered" and balance.feature.consumable == false then
balance.overage_allowed = true
-- Apply to breakdowns as well
if balance.breakdown then
for _, breakdown in ipairs(balance.breakdown) do
breakdown.overage_allowed = true
end
end
end
end
-- -- Helper function to apply legacy continuous_use logic
-- -- Legacy case: continuous_use features always allow overage
-- local function applyContinuousUseLegacy(balance)
-- if balance.feature and balance.feature.type == "metered" and balance.feature.consumable == false and remaining > 0 then
-- balance.overage_allowed = true
-- -- Apply to breakdowns as well
-- if balance.breakdown then
-- for _, breakdown in ipairs(balance.breakdown) do
-- breakdown.overage_allowed = true
-- end
-- end
-- end
-- end
-- Get list of all customer feature IDs
local baseJson = redis.call("GET", cacheKey)
@@ -1089,8 +1166,8 @@ for _, featureId in ipairs(allFeatureIds) do
-- Add id field for compatibility with existing code
balance.id = featureId
-- Apply legacy continuous_use logic
applyContinuousUseLegacy(balance)
-- -- Apply legacy continuous_use logic
-- applyContinuousUseLegacy(balance)
loadedCusFeatures[featureId] = balance
end
@@ -1118,8 +1195,8 @@ for _, entityId in ipairs(entityIds) do
-- Add id field for compatibility with existing code
balance.id = featureId
-- Apply legacy continuous_use logic
applyContinuousUseLegacy(balance)
-- -- Apply legacy continuous_use logic
-- applyContinuousUseLegacy(balance)
entityFeatureStates[entityId][featureId] = balance
end

View File

@@ -47,6 +47,7 @@ export const apiBalanceToAllowed = ({
}
// 4. Balance >= required balance
if (new Decimal(apiBalance.current_balance).gte(requiredBalance)) {
return true;
}

View File

@@ -52,6 +52,7 @@ export const executePostgresTracking = async ({
},
refreshCache: true,
fullCus,
skipAdditionalBalance: true,
});
if (updatedFullCus) {

View File

@@ -60,7 +60,7 @@ export const deductFromCusEnts = async ({
deductions,
overageBehaviour = "cap",
addToAdjustment = false,
skipAdditionalBalance = false,
skipAdditionalBalance = true,
alterGrantedBalance = false,
fullCus,
sortParams,

View File

@@ -1,4 +1,4 @@
import { beforeAll, describe, expect, test } from "bun:test";
import { beforeAll, describe, test } from "bun:test";
import { BillingInterval, LegacyVersion } from "@autumn/shared";
import { TestFeature } from "@tests/setup/v2Features.js";
import ctx from "@tests/utils/testInitUtils/createTestContext.js";
@@ -15,7 +15,6 @@ import {
} from "@/utils/scriptUtils/createTestProducts.js";
import { initCustomerV3 } from "../../src/utils/scriptUtils/testUtils/initCustomerV3.js";
import { initProductsV0 } from "../../src/utils/scriptUtils/testUtils/initProductsV0.js";
import { expectProductAttached } from "../utils/expectUtils/expectProductAttached.js";
import { replaceItems } from "../utils/testProductUtils/testProductUtils.js";
// UNCOMMENT FROM HERE
@@ -23,10 +22,14 @@ const pro = constructProduct({
type: "pro",
items: [
constructArrearItem({
featureId: TestFeature.Words,
includedUsage: 0,
price: 0.2,
// constructArrearItem({
// featureId: TestFeature.Words,
// includedUsage: 0,
// price: 0.2,
// }),
constructFeatureItem({
featureId: TestFeature.Users,
includedUsage: 5,
}),
],
});
@@ -72,11 +75,12 @@ describe(`${chalk.yellowBright("temp: Testing add ons")}`, () => {
product_id: pro.id,
});
await autumn.attach({
customer_id: customerId,
product_id: basic.id,
});
// await autumn.attach({
// customer_id: customerId,
// product_id: basic.id,
// });
});
return;
test("should attach pro product", async () => {
// newPro = structuredClone(pro);
@@ -102,29 +106,4 @@ describe(`${chalk.yellowBright("temp: Testing add ons")}`, () => {
items: newItems,
});
});
test("should checkout and have correct scenario", async () => {
const res = await autumn.checkout({
customer_id: customerId,
product_id: pro.id,
});
expect(res.current_product?.scenario).toBe("renew");
await autumn.attach({
customer_id: customerId,
product_id: pro.id,
});
const customer = await autumn.customers.get(customerId);
expectProductAttached({
customer,
product: pro,
});
const products = customer.products;
expect(products.length).toBe(1);
expect(products[0].id).toBe(pro.id);
expect(products[0].version).toBe(1);
});
});