From e066b3cea9de440ec2d52698d8f02e551423e0df Mon Sep 17 00:00:00 2001 From: John Yeo Date: Fri, 27 Feb 2026 17:08:37 +0000 Subject: [PATCH] fix agent issues --- .../write-test/references/EXPECTATIONS.md | 2 +- .../billingModels/stripe/stripeItemSpec.ts | 3 +- .../lineItemBuilders/usagePriceToLineItem.ts | 11 ++-- .../cusEntsToPrepaidInvoiceOverage.ts | 52 +++++++++++++++++++ .../utils/cusEntUtils/classifyCusEntUtils.ts | 7 +++ shared/utils/cusEntUtils/index.ts | 2 +- ...customerProductsToStripeSubscriptionIds.ts | 2 +- 7 files changed, 67 insertions(+), 12 deletions(-) create mode 100644 shared/utils/cusEntUtils/balanceUtils/cusEntsToPrepaidInvoiceOverage.ts diff --git a/.claude/skills/write-test/references/EXPECTATIONS.md b/.claude/skills/write-test/references/EXPECTATIONS.md index 90431ff0c..55f28bf9e 100644 --- a/.claude/skills/write-test/references/EXPECTATIONS.md +++ b/.claude/skills/write-test/references/EXPECTATIONS.md @@ -291,7 +291,7 @@ await expectStripeSubscriptionCorrect({ subCount?: number, // Expected total subscription count subId?: string, // Verify a specific subscription only status?: "active" | "trialing", - shouldBeCanceled?: boolean, // Override: expect canceling state + shouldBeCanceling?: boolean, // Override: expect canceling state rewards?: string[], // Expected coupon/discount IDs debug?: boolean, // Log detailed comparison info }, diff --git a/shared/models/billingModels/stripe/stripeItemSpec.ts b/shared/models/billingModels/stripe/stripeItemSpec.ts index 8ba6215c6..cadb93fd2 100644 --- a/shared/models/billingModels/stripe/stripeItemSpec.ts +++ b/shared/models/billingModels/stripe/stripeItemSpec.ts @@ -7,11 +7,12 @@ import type { FullProduct } from "../../productModels/productModels"; /** * Inline Stripe price data for entity-scoped items. * Pre-calculated flat amount (not tiered) — Stripe doesn't support tiered price_data. + * `recurring` is omitted for one-off prices. */ export type StripeInlinePrice = { product: string; currency: string; - recurring: Stripe.PriceCreateParams.Recurring; + recurring?: Stripe.PriceCreateParams.Recurring; unit_amount_decimal: string; }; diff --git a/shared/utils/billingUtils/invoicingUtils/lineItemBuilders/usagePriceToLineItem.ts b/shared/utils/billingUtils/invoicingUtils/lineItemBuilders/usagePriceToLineItem.ts index 42998c241..d5f15d7c3 100644 --- a/shared/utils/billingUtils/invoicingUtils/lineItemBuilders/usagePriceToLineItem.ts +++ b/shared/utils/billingUtils/invoicingUtils/lineItemBuilders/usagePriceToLineItem.ts @@ -1,4 +1,5 @@ import { cusEntsToAllowance } from "@utils/cusEntUtils"; +import { cusEntToPrepaidInvoiceOverage } from "@utils/cusEntUtils/balanceUtils/cusEntsToPrepaidInvoiceOverage"; import { Decimal } from "decimal.js"; import { InternalError } from "../../../../api/errors/base/InternalError"; import type { LineItemContext } from "../../../../models/billingModels/lineItem/lineItemContext"; @@ -11,7 +12,6 @@ import { cusEntToInvoiceUsage } from "../../../cusEntUtils/overageUtils/cusEntTo import { isConsumablePrice, isPrepaidPrice, - isVolumePrice, } from "../../../productUtils/priceUtils/classifyPriceUtils"; import { usagePriceToLineDescription } from "../descriptionUtils/usagePriceToLineDescription"; import { priceToLineAmount } from "../lineItemUtils/priceToLineAmount"; @@ -49,12 +49,10 @@ export const usagePriceToLineItem = ({ const price = cusPrice.price; // 1. Get overage + // don't use upcoming quantity for prepaid prices by default. THe price that users have paid currently is quantity. let overage = 0; if (isPrepaidPrice(cusPrice.price)) { - overage = cusEntsToPrepaidQuantity({ - cusEnts: [cusEnt], - sumAcrossEntities: false, - }); + overage = cusEntToPrepaidInvoiceOverage({ cusEnt }); } else { overage = cusEntToInvoiceOverage({ cusEnt }); } @@ -63,9 +61,6 @@ export const usagePriceToLineItem = ({ // which tier applies, and the ENTIRE total is charged at that tier's rate. // So we add allowance back to overage before pricing. const allowance = cusEntsToAllowance({ cusEnts: [cusEnt] }); - if (isVolumePrice(cusPrice.price)) { - overage = new Decimal(overage).add(allowance).toNumber(); - } // 2. Get usage let usage = 0; diff --git a/shared/utils/cusEntUtils/balanceUtils/cusEntsToPrepaidInvoiceOverage.ts b/shared/utils/cusEntUtils/balanceUtils/cusEntsToPrepaidInvoiceOverage.ts new file mode 100644 index 000000000..8da3342bc --- /dev/null +++ b/shared/utils/cusEntUtils/balanceUtils/cusEntsToPrepaidInvoiceOverage.ts @@ -0,0 +1,52 @@ +import { cusEntToCusPrice } from "@utils/cusEntUtils/convertCusEntUtils/cusEntToCusPrice"; +import { Decimal } from "decimal.js"; +import { + type FullCusEntWithFullCusProduct, + isPrepaidPrice, + isVolumeBasedCusEnt, + sumValues, +} from "../../.."; +import { cusEntToPrepaidQuantity } from "./cusEntsToPrepaidQuantity"; +import { cusEntsToAllowance } from "./grantedBalanceUtils/cusEntsToAllowance"; + +export const cusEntToPrepaidInvoiceOverage = ({ + cusEnt, + useUpcomingQuantity = false, +}: { + cusEnt: FullCusEntWithFullCusProduct; + useUpcomingQuantity?: boolean; +}) => { + // 2. If cus ent is not prepaid, skip + const cusPrice = cusEntToCusPrice({ cusEnt }); + + if (!cusPrice || !isPrepaidPrice(cusPrice.price)) return 0; + + if (!cusEnt.customer_product) return 0; + + // 3. Get quantity + const prepaidQuantity = cusEntToPrepaidQuantity({ + cusEnt, + useUpcomingQuantity, + }); + const allowance = cusEntsToAllowance({ cusEnts: [cusEnt] }); + + const isVolume = isVolumeBasedCusEnt(cusEnt); + + return isVolume + ? new Decimal(prepaidQuantity).add(allowance).toNumber() + : prepaidQuantity; +}; + +export const cusEntsToPrepaidInvoiceOverage = ({ + cusEnts, +}: { + cusEnts: FullCusEntWithFullCusProduct[]; +}) => { + return sumValues( + cusEnts.map((cusEnt) => + cusEntToPrepaidInvoiceOverage({ + cusEnt, + }), + ), + ); +}; diff --git a/shared/utils/cusEntUtils/classifyCusEntUtils.ts b/shared/utils/cusEntUtils/classifyCusEntUtils.ts index d3326c463..9d75011e4 100644 --- a/shared/utils/cusEntUtils/classifyCusEntUtils.ts +++ b/shared/utils/cusEntUtils/classifyCusEntUtils.ts @@ -1,5 +1,6 @@ import { InternalError } from "@api/errors"; import { ms } from "@utils/common"; +import { isVolumePrice } from "@utils/productUtils/priceUtils/classifyPriceUtils"; import type { EntityBalance, FullCustomerEntitlement, @@ -101,3 +102,9 @@ export const customerEntitlementShouldBeBilled = ({ return nextResetAt <= invoicePeriodEndMs + TOLERANCE_MS; }; + +export const isVolumeBasedCusEnt = (cusEnt: FullCusEntWithFullCusProduct) => { + const cusPrice = cusEntToCusPrice({ cusEnt }); + if (!cusPrice) return false; + return isVolumePrice(cusPrice.price); +}; diff --git a/shared/utils/cusEntUtils/index.ts b/shared/utils/cusEntUtils/index.ts index 68ada5877..1f3915724 100644 --- a/shared/utils/cusEntUtils/index.ts +++ b/shared/utils/cusEntUtils/index.ts @@ -4,6 +4,7 @@ export * from "./balanceUtils"; export * from "./balanceUtils/cusEntsToBalance"; export * from "./balanceUtils/cusEntsToCurrentBalance"; +export * from "./balanceUtils/cusEntsToPrepaidInvoiceOverage"; export * from "./balanceUtils/cusEntsToPrepaidQuantity"; export * from "./balanceUtils/cusEntsToPurchasedBalance"; export * from "./balanceUtils/cusEntsToReset"; @@ -21,7 +22,6 @@ export * from "./balanceUtils/rollovers/cusEntsToRolloverBalance"; export * from "./balanceUtils/rollovers/cusEntsToRolloverGranted"; export * from "./balanceUtils/rollovers/cusEntsToRolloverUsage"; export * from "./balanceUtils/rollovers/cusEntsToRolloverUsage"; - // Classify utils export * from "./classifyCusEntUtils"; // Convert utils barrel diff --git a/shared/utils/cusProductUtils/convertCusProduct/customerProductsToStripeSubscriptionIds.ts b/shared/utils/cusProductUtils/convertCusProduct/customerProductsToStripeSubscriptionIds.ts index 01b08c494..4007672f2 100644 --- a/shared/utils/cusProductUtils/convertCusProduct/customerProductsToStripeSubscriptionIds.ts +++ b/shared/utils/cusProductUtils/convertCusProduct/customerProductsToStripeSubscriptionIds.ts @@ -6,6 +6,6 @@ export const customerProductsToStripeSubscriptionIds = ({ customerProducts: FullCusProduct[]; }) => { return deduplicateArray( - customerProducts.flatMap((cp) => cp.subscription_ids), + customerProducts.flatMap((cp) => cp.subscription_ids ?? []), ); };