mirror of
https://github.com/simstudioai/sim.git
synced 2026-08-29 02:27:35 +08:00
improvement(billing): ledger-only usage + period-advance cycle close (#7078)
* improvement(billing): make usage ledger-only and close cycles off period advance Usage is now the attributed usage_log ledger everywhere: the userStats baselines (currentPeriodCost / currentPeriodCopilotCost), the includeLegacyBaseline compatibility flag, the pro-snapshot join/leave machinery, and departedMemberUsage accrual are removed from all read and write paths. Cycle rollover (final sub-threshold overage collection, billedOverageThisPeriod reset, last-period bookkeeping) moves off the invoice.finalized payload parsing — dead for org subscriptions since May — onto a period-advance sweep with a durable per-subscription close marker (subscription.last_closed_period_start), transaction-enlisted Stripe outbox invoicing, and stamp-matched ledger sums. Enterprise closes are bookkeeping-only; reporting-anchor orgs advance the marker alone. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(billing): include departed actors in close refresh and gate threshold billing on close currency Cycle close now unions current members with every actor holding org-stamped ledger rows in the closed period, so a departed member's daily-refresh consumption offsets the final overage exactly like their billed usage. Threshold billing defers with a pending-cycle-close no-op while a subscription's close marker lags its current period, so the shared billedOverageThisPeriod tracker can never mix an elapsed period's settlements with the new period's. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(billing): seal cycle-close races and align refresh actors with billed usage Threshold settlement revalidates the close marker and period under the tracker lock via the same isSubscriptionCycleCloseCurrent predicate the preflight uses, so a rollover between check and transaction aborts as a concurrent-state retry instead of settling against the wrong cycle. Terminal bookkeeping claims the close marker with its tracker reset, so a deletion racing an in-flight sweep close serializes through the one marker and the loser rolls back rather than re-billing settled overage; anchored enterprise deletions only claim the marker since their windows derive from the anchor, not Stripe bounds. A close with overage due but missing Stripe identifiers now defers loudly instead of claiming the marker and silently forgiving the money, the closed window's start derives from the ledger's own period stamps so anchor-day drift cannot misalign the refresh window, calculateSubscriptionOverage unions departed ledger actors into the org refresh deduction like the close does, and blocked accounts report their real ledger usage while staying blocked. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(billing): defer ownerless org closes and make the drift test load-bearing A close with overage due but no owner-role member now defers loudly like the missing-Stripe-identifier case instead of claiming the marker and silently forgiving the money. The stamp-drift test pins the marker before the stamped boundary so only the ledger-stamp lookup can produce the asserted window. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * chore(helm): bump chart to 1.6.0 for the billing-cycle-close cron job Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(billing): revalidate the org roster under close locks and align invoice labels The close re-reads the member roster inside its transaction and defers on any change, mirroring threshold billing — an owner transfer moves the billed-overage tracker between rows, so a pre-lock roster could settle against the wrong tracker. Invoice labels now use the closed period's end month like every other overage path, and the sweep test's rows are shaped like rows the candidate query can actually return. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(billing): hold cycle close for a settlement grace after rollover Billing attribution is frozen at run start, so a run straddling a rollover can insert elapsed-period-stamped rows after the period ends. Closing only once the rollover is older than any possible in-flight run guarantees the close's ledger sums are final; the sweep picks the period up on a later run. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(billing): resolve reporting windows through the canonical period resolver The close paths now ask resolveSubscriptionUsagePeriod whether a subscription derives its windows from a reporting anchor instead of re-checking metadata shape locally, so a malformed hand-edited anchor that the resolver rejects (falling back to Stripe bounds) books its Stripe-stamped ledger rows normally instead of skipping bookkeeping. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(billing): union departed ledger actors in org threshold settlement Organization threshold billing now reads the period ledger per user and unions the actors holding org-attributed rows with the current roster before computing refresh deductions — the same actor set calculateSubscriptionOverage and the cycle close use — so a departed member's usage cannot be settled without their daily-refresh offset. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(billing): claim the terminal period before deletion settlement and delete vestigial refresh bounds Subscription deletion now claims the close marker from the fresh subscription row before computing or charging final overage, serializing with the cycle-close sweep so both paths can never bill the same period — an in-flight close fails its guarded claim and rolls back, and the deletion settles against the row's real period instead of a possibly stale webhook payload. The per-user refresh bounds machinery is deleted outright: its only source was proPeriodCostSnapshotAt, which this PR stopped writing, and ledger entity stamps already scope refresh to org-attributed rows — a joiner's pre-join usage is user-stamped and can never enter the org refresh scan, while a departed member's org-stamped rows participate exactly like a current member's. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(billing): scope daily refresh by entity stamps and close lagging periods before deletion settlement The refresh actor-list threading (userIds + departed-actor unions) violated the entity-stamp principle the ledger sums already follow: org-attributed rows from departed members counted in pooled usage but never consumed refresh on the monitor/resolved-usage paths. Daily refresh now scopes rows by the same write-time entity and period stamps as the ledger — no actor list anywhere — which deletes the unions, the rollup memberIds, and the org-specific refresh variant. Deletion settlement now closes any elapsed-but-unclosed period (grace bypassed — no later sweep revisits a canceled sub) before claiming the terminal period, so a deletion racing the sweep can no longer jump the marker past an unsettled period and silently forgive its final overage. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(billing): bucket refresh by clamped day so stamped stragglers stay in the deduction Refresh membership is now the entity/period stamps alone — identical to the ledger sums it offsets. A row written after the rollover but stamped to the elapsed period (attribution frozen at run start) is billed by the stamp-based close, so it must consume refresh too; created-at now only assigns the day bucket, clamped into the period, instead of excluding the row entirely. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(billing): pair the overage tracker with the marker's period and pin test clocks billedOverageThisPeriod only ever holds collections for the period that began at the close marker — the threshold gate blocks settlement whenever the marker lags. Both consumers now honor that pairing: a close that skipped forgiven periods counts nothing from the tracker against the period it bills, and the deletion settlement ignores the tracker when the marker was still lagging at claim time. Ignoring is provably safe in both cases because a lagging marker means no current-period collections exist. The cycle-close and daily-refresh suites pin the system clock: their grace and window checks compare fixed period fixtures against Date.now(), which made them dependent on the host date. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(billing): reject lagging terminal claims and thread billingInterval into deletion bookkeeping claimTerminalPeriod no longer advances the marker over an unclosed elapsed period: a lagging marker is reported without a write so the deletion handler can run the elapsed close once more (healing a rollover that committed between close and claim) and only then seal the marker explicitly, with an error log, when the period is genuinely unclosable. Sealing preserves the in-flight-sweep abort guarantee. Deletion bookkeeping now passes the subscription's billingInterval through, so an enterprise reporting subscription whose interval lives on the row column (not metadata) still resolves as reporting-anchored and keeps its bookkeeping no-op, matching every other resolver call site. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
committed by
GitHub
parent
9adb4051a1
commit
a25d993266
@@ -0,0 +1,57 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { generateShortId } from '@sim/utils/id'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { verifyCronAuth } from '@/lib/auth/internal'
|
||||
import { sweepBillingCycleCloses } from '@/lib/billing/cycle-close'
|
||||
import { acquireLock, releaseLock } from '@/lib/core/config/redis'
|
||||
import { runDetached } from '@/lib/core/utils/background'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
|
||||
const logger = createLogger('BillingCycleCloseCron')
|
||||
|
||||
const LOCK_KEY = 'billing-cycle-close-lock'
|
||||
/** Lock TTL in seconds — generous enough to cover the full sweep. */
|
||||
const LOCK_TTL_SECONDS = 15 * 60
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
/**
|
||||
* Cron endpoint that closes elapsed billing periods (final overage collection,
|
||||
* `billedOverageThisPeriod` reset, last-period bookkeeping). Configured in
|
||||
* helm/sim/values.yaml under cronjobs.jobs.billingCycleClose.
|
||||
*
|
||||
* Acknowledges the cron call immediately and sweeps in the background; a Redis
|
||||
* lock prevents overlapping runs, and each subscription's close is durably
|
||||
* marked (`subscription.last_closed_period_start`), so replays are no-ops.
|
||||
*/
|
||||
export const GET = withRouteHandler(async (request: NextRequest) => {
|
||||
const authError = verifyCronAuth(request, 'Billing cycle close')
|
||||
if (authError) {
|
||||
return authError
|
||||
}
|
||||
|
||||
const lockValue = generateShortId()
|
||||
const locked = await acquireLock(LOCK_KEY, lockValue, LOCK_TTL_SECONDS, {
|
||||
reclaimOnFailure: true,
|
||||
})
|
||||
if (!locked) {
|
||||
return NextResponse.json(
|
||||
{ success: true, message: 'Cycle-close sweep already in progress – skipped', status: 'skip' },
|
||||
{ status: 202 }
|
||||
)
|
||||
}
|
||||
|
||||
runDetached('billing-cycle-close', async () => {
|
||||
try {
|
||||
const summary = await sweepBillingCycleCloses()
|
||||
logger.info('Billing cycle-close sweep completed', { ...summary })
|
||||
} finally {
|
||||
await releaseLock(LOCK_KEY, lockValue).catch(() => {})
|
||||
}
|
||||
})
|
||||
|
||||
return NextResponse.json(
|
||||
{ success: true, message: 'Billing cycle-close sweep started', status: 'started' },
|
||||
{ status: 202 }
|
||||
)
|
||||
})
|
||||
@@ -89,7 +89,6 @@ export const GET = withRouteHandler(
|
||||
if (includeUsage && hasAdminAccess) {
|
||||
const usageData = await db
|
||||
.select({
|
||||
currentPeriodCost: userStats.currentPeriodCost,
|
||||
currentUsageLimit: userStats.currentUsageLimit,
|
||||
usageLimitUpdatedAt: userStats.usageLimitUpdatedAt,
|
||||
lastPeriodCost: userStats.lastPeriodCost,
|
||||
@@ -99,20 +98,19 @@ export const GET = withRouteHandler(
|
||||
.limit(1)
|
||||
|
||||
if (usageData.length > 0) {
|
||||
const { billingPeriod, includeLegacyBaseline, usageByUser } =
|
||||
await getOrganizationMemberUsageSnapshot(organizationId, {
|
||||
const { billingPeriod, usageByUser } = await getOrganizationMemberUsageSnapshot(
|
||||
organizationId,
|
||||
{
|
||||
executor: dbReplica,
|
||||
userIds: [memberId],
|
||||
})
|
||||
}
|
||||
)
|
||||
const memberLedger = usageByUser.get(memberId) ?? 0
|
||||
memberData = {
|
||||
...memberData,
|
||||
usage: {
|
||||
...usageData[0],
|
||||
currentPeriodCost: (
|
||||
(includeLegacyBaseline ? Number(usageData[0].currentPeriodCost ?? 0) : 0) +
|
||||
memberLedger
|
||||
).toString(),
|
||||
currentPeriodCost: memberLedger.toString(),
|
||||
billingPeriodStart: billingPeriod?.start ?? null,
|
||||
billingPeriodEnd: billingPeriod?.end ?? null,
|
||||
},
|
||||
|
||||
@@ -101,7 +101,6 @@ export const GET = withRouteHandler(
|
||||
createdAt: member.createdAt,
|
||||
userName: user.name,
|
||||
userEmail: user.email,
|
||||
currentPeriodCost: userStats.currentPeriodCost,
|
||||
currentUsageLimit: userStats.currentUsageLimit,
|
||||
usageLimitUpdatedAt: userStats.usageLimitUpdatedAt,
|
||||
})
|
||||
@@ -115,19 +114,18 @@ export const GET = withRouteHandler(
|
||||
totalQuery,
|
||||
])
|
||||
|
||||
const { billingPeriod, includeLegacyBaseline, usageByUser } =
|
||||
await getOrganizationMemberUsageSnapshot(organizationId, {
|
||||
const { billingPeriod, usageByUser } = await getOrganizationMemberUsageSnapshot(
|
||||
organizationId,
|
||||
{
|
||||
userIds: base.map((row) => row.userId),
|
||||
})
|
||||
}
|
||||
)
|
||||
const billingPeriodStart = billingPeriod?.start ?? null
|
||||
const billingPeriodEnd = billingPeriod?.end ?? null
|
||||
|
||||
const membersWithUsage = base.map((row) => ({
|
||||
...row,
|
||||
currentPeriodCost: (
|
||||
(includeLegacyBaseline ? Number(row.currentPeriodCost ?? 0) : 0) +
|
||||
(usageByUser.get(row.userId) ?? 0)
|
||||
).toString(),
|
||||
currentPeriodCost: (usageByUser.get(row.userId) ?? 0).toString(),
|
||||
billingPeriodStart,
|
||||
billingPeriodEnd,
|
||||
}))
|
||||
|
||||
@@ -90,7 +90,6 @@ export const GET = withRouteHandler(
|
||||
createdAt: member.createdAt,
|
||||
userName: user.name,
|
||||
userEmail: user.email,
|
||||
currentPeriodCost: userStats.currentPeriodCost,
|
||||
currentUsageLimit: userStats.currentUsageLimit,
|
||||
billingBlocked: userStats.billingBlocked,
|
||||
})
|
||||
@@ -104,10 +103,9 @@ export const GET = withRouteHandler(
|
||||
return notFoundResponse('Member')
|
||||
}
|
||||
|
||||
const { includeLegacyBaseline, usageByUser } = await getOrganizationMemberUsageSnapshot(
|
||||
organizationId,
|
||||
{ userIds: [memberData.userId] }
|
||||
)
|
||||
const { usageByUser } = await getOrganizationMemberUsageSnapshot(organizationId, {
|
||||
userIds: [memberData.userId],
|
||||
})
|
||||
|
||||
const data: AdminMemberDetail = {
|
||||
id: memberData.id,
|
||||
@@ -117,10 +115,7 @@ export const GET = withRouteHandler(
|
||||
createdAt: memberData.createdAt.toISOString(),
|
||||
userName: memberData.userName,
|
||||
userEmail: memberData.userEmail,
|
||||
currentPeriodCost: (
|
||||
(includeLegacyBaseline ? Number(memberData.currentPeriodCost ?? 0) : 0) +
|
||||
(usageByUser.get(memberData.userId) ?? 0)
|
||||
).toString(),
|
||||
currentPeriodCost: (usageByUser.get(memberData.userId) ?? 0).toString(),
|
||||
currentUsageLimit: memberData.currentUsageLimit,
|
||||
billingBlocked: memberData.billingBlocked ?? false,
|
||||
}
|
||||
|
||||
@@ -118,7 +118,6 @@ export const GET = withRouteHandler(
|
||||
createdAt: member.createdAt,
|
||||
userName: user.name,
|
||||
userEmail: user.email,
|
||||
currentPeriodCost: userStats.currentPeriodCost,
|
||||
currentUsageLimit: userStats.currentUsageLimit,
|
||||
billingBlocked: userStats.billingBlocked,
|
||||
})
|
||||
@@ -133,12 +132,9 @@ export const GET = withRouteHandler(
|
||||
|
||||
const total = countResult[0].count
|
||||
|
||||
const { includeLegacyBaseline, usageByUser } = await getOrganizationMemberUsageSnapshot(
|
||||
organizationId,
|
||||
{
|
||||
userIds: membersData.map((row) => row.userId),
|
||||
}
|
||||
)
|
||||
const { usageByUser } = await getOrganizationMemberUsageSnapshot(organizationId, {
|
||||
userIds: membersData.map((row) => row.userId),
|
||||
})
|
||||
|
||||
const data: AdminMemberDetail[] = membersData.map((m) => ({
|
||||
id: m.id,
|
||||
@@ -148,10 +144,7 @@ export const GET = withRouteHandler(
|
||||
createdAt: m.createdAt.toISOString(),
|
||||
userName: m.userName,
|
||||
userEmail: m.userEmail,
|
||||
currentPeriodCost: (
|
||||
(includeLegacyBaseline ? Number(m.currentPeriodCost ?? 0) : 0) +
|
||||
(usageByUser.get(m.userId) ?? 0)
|
||||
).toString(),
|
||||
currentPeriodCost: (usageByUser.get(m.userId) ?? 0).toString(),
|
||||
currentUsageLimit: m.currentUsageLimit,
|
||||
billingBlocked: m.billingBlocked ?? false,
|
||||
}))
|
||||
|
||||
@@ -12,7 +12,8 @@
|
||||
* Body:
|
||||
* - currentUsageLimit?: number | null - Usage limit (null to use default)
|
||||
* - billingBlocked?: boolean - Block/unblock billing
|
||||
* - currentPeriodCost?: number - Reset/adjust current period cost (use with caution)
|
||||
* - currentPeriodCost?: number - Deprecated no-op: usage is the attributed
|
||||
* usage_log ledger and cannot be adjusted here
|
||||
* - reason?: string - Reason for the change (for audit logging)
|
||||
*
|
||||
* Response: AdminSingleResponse<{ success: true, updated: string[], warnings: string[] }>
|
||||
@@ -79,9 +80,8 @@ export const GET = withRouteHandler(
|
||||
|
||||
const [stats] = await db.select().from(userStats).where(eq(userStats.userId, userId)).limit(1)
|
||||
|
||||
// currentPeriodCost is now only a baseline; canonical current-period usage
|
||||
// (baseline + attributed usage_log, refresh-adjusted) comes from the same
|
||||
// helper users see, so admin reflects real usage instead of a stale 0.
|
||||
// Canonical current-period usage (attributed usage_log, refresh-adjusted)
|
||||
// comes from the same helper users see.
|
||||
const usage = await getUserUsageData(userId)
|
||||
|
||||
const memberOrgs = await db
|
||||
@@ -197,10 +197,10 @@ export const PATCH = withRouteHandler(
|
||||
if (currentUsageLimit === null) {
|
||||
updateData.currentUsageLimit = null
|
||||
} else {
|
||||
const currentCost = Number.parseFloat(existingStats?.currentPeriodCost || '0')
|
||||
if (currentUsageLimit < currentCost) {
|
||||
const { currentUsage } = await getUserUsageData(userId)
|
||||
if (currentUsageLimit < currentUsage) {
|
||||
warnings.push(
|
||||
`New limit ($${currentUsageLimit.toFixed(2)}) is below current usage ($${currentCost.toFixed(2)}). User may be immediately blocked.`
|
||||
`New limit ($${currentUsageLimit.toFixed(2)}) is below current usage ($${currentUsage.toFixed(2)}). User may be immediately blocked.`
|
||||
)
|
||||
}
|
||||
updateData.currentUsageLimit = currentUsageLimit.toFixed(2)
|
||||
@@ -225,13 +225,9 @@ export const PATCH = withRouteHandler(
|
||||
}
|
||||
|
||||
if (currentPeriodCost !== undefined) {
|
||||
const previousCost = existingStats?.currentPeriodCost || '0'
|
||||
warnings.push(
|
||||
`Manually adjusting currentPeriodCost from $${previousCost} to $${currentPeriodCost.toFixed(2)}. This may affect billing accuracy.`
|
||||
'currentPeriodCost adjustments are deprecated: usage is the attributed usage_log ledger and cannot be edited here. The field was ignored.'
|
||||
)
|
||||
|
||||
updateData.currentPeriodCost = currentPeriodCost.toFixed(2)
|
||||
updated.push('currentPeriodCost')
|
||||
}
|
||||
|
||||
if (updated.length === 0) {
|
||||
@@ -256,7 +252,6 @@ export const PATCH = withRouteHandler(
|
||||
? {
|
||||
currentUsageLimit: existingStats.currentUsageLimit,
|
||||
billingBlocked: existingStats.billingBlocked,
|
||||
currentPeriodCost: existingStats.currentPeriodCost,
|
||||
}
|
||||
: null,
|
||||
newValues: updateData,
|
||||
|
||||
@@ -291,13 +291,13 @@ describe('listDashboardOrganizations', () => {
|
||||
externalCollaboratorCount: 0,
|
||||
planLabel: 'No plan',
|
||||
})
|
||||
// Pagination, membership/collaborators, and two batched usage aggregates.
|
||||
// Pagination, membership/collaborators, and the batched ledger aggregate.
|
||||
// This count remains constant regardless of the number of organizations.
|
||||
expect(dbChainMockFns.select).toHaveBeenCalledTimes(6)
|
||||
expect(dbChainMockFns.select).toHaveBeenCalledTimes(5)
|
||||
expect(dbChainMockFns.selectDistinctOn).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('preserves the frozen baseline for an Enterprise subscription using its Stripe period', async () => {
|
||||
it('reports ledger usage for an Enterprise subscription using its Stripe period', async () => {
|
||||
queueTableRows(organization, [{ total: 1 }])
|
||||
queueTableRows(organization, [
|
||||
{ id: 'org-1', name: 'One', orgUsageLimit: '100', creditBalance: '0' },
|
||||
@@ -325,13 +325,12 @@ describe('listDashboardOrganizations', () => {
|
||||
},
|
||||
])
|
||||
queueTableRows(usageLog, [{ organizationId: 'org-1', cost: '2.5', workflowRuns: 3 }])
|
||||
queueTableRows(member, [{ organizationId: 'org-1', cost: '1.5' }])
|
||||
|
||||
const result = await listDashboardOrganizations({ search: '', limit: 50, offset: 0 })
|
||||
|
||||
expect(result.data[0]).toMatchObject({
|
||||
reportingPeriod: { source: 'stripe' },
|
||||
usage: { usedDollars: 4, workflowRuns: 3 },
|
||||
usage: { usedDollars: 2.5, workflowRuns: 3 },
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -254,24 +254,6 @@ async function getDashboardOrganizationUsage(
|
||||
}
|
||||
}
|
||||
|
||||
const legacyOrganizationIds = contexts
|
||||
.filter((context) => context.period.source !== 'reporting')
|
||||
.map((context) => context.organizationId)
|
||||
if (legacyOrganizationIds.length > 0) {
|
||||
const baselineTotals = await db
|
||||
.select({
|
||||
organizationId: member.organizationId,
|
||||
cost: sql<string>`coalesce(sum(${userStats.currentPeriodCost}), 0)`,
|
||||
})
|
||||
.from(member)
|
||||
.leftJoin(userStats, eq(userStats.userId, member.userId))
|
||||
.where(inArray(member.organizationId, legacyOrganizationIds))
|
||||
.groupBy(member.organizationId)
|
||||
for (const row of baselineTotals) {
|
||||
const usage = result.get(row.organizationId)
|
||||
if (usage) usage.total += Number(row.cost)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -307,34 +289,6 @@ async function getDashboardOrganizationUsage(
|
||||
)
|
||||
}
|
||||
|
||||
const legacyOrganizationIds = contexts
|
||||
.filter((context) => context.period.source !== 'reporting')
|
||||
.map((context) => context.organizationId)
|
||||
if (legacyOrganizationIds.length > 0) {
|
||||
const baselineRows = await db
|
||||
.select({
|
||||
organizationId: member.organizationId,
|
||||
userId: member.userId,
|
||||
cost: userStats.currentPeriodCost,
|
||||
})
|
||||
.from(member)
|
||||
.leftJoin(userStats, eq(userStats.userId, member.userId))
|
||||
.where(
|
||||
options.userIds
|
||||
? and(
|
||||
inArray(member.organizationId, legacyOrganizationIds),
|
||||
inArray(member.userId, options.userIds)
|
||||
)
|
||||
: inArray(member.organizationId, legacyOrganizationIds)
|
||||
)
|
||||
for (const row of baselineRows) {
|
||||
const usage = result.get(row.organizationId)
|
||||
if (!usage) continue
|
||||
const amount = Number(row.cost ?? 0)
|
||||
usage.total += amount
|
||||
usage.byUser.set(row.userId, (usage.byUser.get(row.userId) ?? 0) + amount)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -661,22 +615,6 @@ export async function listDashboardUsers({ search, limit, offset }: PaginationIn
|
||||
: []
|
||||
)
|
||||
)
|
||||
const legacyPersonalIds = personalUserIds.filter(
|
||||
(userId) => personalPeriods.get(userId)?.source !== 'reporting'
|
||||
)
|
||||
if (legacyPersonalIds.length > 0) {
|
||||
const baselineRows = await db
|
||||
.select({ userId: userStats.userId, cost: userStats.currentPeriodCost })
|
||||
.from(userStats)
|
||||
.where(inArray(userStats.userId, legacyPersonalIds))
|
||||
for (const row of baselineRows) {
|
||||
const current = personalUsage.get(row.userId) ?? { dollars: 0, workflowRuns: 0 }
|
||||
personalUsage.set(row.userId, {
|
||||
...current,
|
||||
dollars: current.dollars + Number(row.cost ?? 0),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
data: rows.map((row) => {
|
||||
|
||||
@@ -137,6 +137,7 @@ export const adminV1TransferOwnershipBodySchema = z.object({
|
||||
const adminV1OrganizationMemberMutationResultSchema = adminV1MemberSchema.extend({
|
||||
action: z.enum(['created', 'updated', 'already_member']),
|
||||
billingActions: z.object({
|
||||
/** @deprecated Always false — ledger entity stamps replaced join-time snapshots. */
|
||||
proUsageSnapshotted: z.boolean(),
|
||||
proCancelledAtPeriodEnd: z.boolean(),
|
||||
}),
|
||||
@@ -147,9 +148,10 @@ const adminV1RemoveOrganizationMemberResultSchema = z.object({
|
||||
memberId: z.string(),
|
||||
userId: z.string(),
|
||||
billingActions: z.object({
|
||||
/** Dollar amount of departed-member usage captured (0 when none). */
|
||||
/** @deprecated Always 0 — a departed member's ledger rows stay stamped to the org's period. */
|
||||
usageCaptured: z.number(),
|
||||
proRestored: z.boolean(),
|
||||
/** @deprecated Always false — no snapshot exists to restore. */
|
||||
usageRestored: z.boolean(),
|
||||
skipBillingLogic: z.boolean(),
|
||||
}),
|
||||
|
||||
@@ -70,7 +70,6 @@ import { handleAbandonedCheckout } from '@/lib/billing/webhooks/checkout'
|
||||
import { handleChargeDispute, handleDisputeClosed } from '@/lib/billing/webhooks/disputes'
|
||||
import { handleManualEnterpriseSubscription } from '@/lib/billing/webhooks/enterprise'
|
||||
import {
|
||||
handleInvoiceFinalized,
|
||||
handleInvoicePaymentFailed,
|
||||
handleInvoicePaymentSucceeded,
|
||||
} from '@/lib/billing/webhooks/invoices'
|
||||
@@ -1502,10 +1501,6 @@ export const auth = betterAuth({
|
||||
await handleInvoicePaymentFailed(event)
|
||||
break
|
||||
}
|
||||
case 'invoice.finalized': {
|
||||
await handleInvoiceFinalized(event)
|
||||
break
|
||||
}
|
||||
case 'customer.subscription.created':
|
||||
case 'customer.subscription.updated': {
|
||||
await handleManualEnterpriseSubscription(event)
|
||||
|
||||
@@ -8,20 +8,16 @@ const {
|
||||
mockGetBillingPeriodUsageCost,
|
||||
mockGetOrgMemberUsageForBillingPeriod,
|
||||
mockGetOrgMemberUsageLimit,
|
||||
mockGetPooledOrgCurrentPeriodCost,
|
||||
mockGetUserUsageLimit,
|
||||
mockIsOrganizationBillingBlocked,
|
||||
mockComputeBillingPeriodUsageWithDailyRefresh,
|
||||
mockGetOrgMemberRefreshBounds,
|
||||
} = vi.hoisted(() => ({
|
||||
mockGetBillingPeriodUsageCost: vi.fn(),
|
||||
mockGetOrgMemberUsageForBillingPeriod: vi.fn(),
|
||||
mockGetOrgMemberUsageLimit: vi.fn(),
|
||||
mockGetPooledOrgCurrentPeriodCost: vi.fn(),
|
||||
mockGetUserUsageLimit: vi.fn(),
|
||||
mockIsOrganizationBillingBlocked: vi.fn(),
|
||||
mockComputeBillingPeriodUsageWithDailyRefresh: vi.fn(),
|
||||
mockGetOrgMemberRefreshBounds: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/billing/organizations/member-limits', () => ({
|
||||
@@ -33,10 +29,9 @@ vi.mock('@/lib/billing/core/access', () => ({
|
||||
isOrganizationBillingBlocked: mockIsOrganizationBillingBlocked,
|
||||
}))
|
||||
|
||||
// core/usage pulls in the email-rendering chain at import; stub the two symbols
|
||||
// core/usage pulls in the email-rendering chain at import; stub the symbol
|
||||
// usage-monitor imports from it so the module loads in a node test env.
|
||||
vi.mock('@/lib/billing/core/usage', () => ({
|
||||
getPooledOrgCurrentPeriodCost: mockGetPooledOrgCurrentPeriodCost,
|
||||
getUserUsageLimit: mockGetUserUsageLimit,
|
||||
}))
|
||||
|
||||
@@ -46,13 +41,13 @@ vi.mock('@/lib/billing/core/usage-log', () => ({
|
||||
|
||||
vi.mock('@/lib/billing/credits/daily-refresh', () => ({
|
||||
computeBillingPeriodUsageWithDailyRefresh: mockComputeBillingPeriodUsageWithDailyRefresh,
|
||||
getOrgMemberRefreshBounds: mockGetOrgMemberRefreshBounds,
|
||||
}))
|
||||
|
||||
import {
|
||||
checkBillingBlocked,
|
||||
checkBillingEntityBlocked,
|
||||
checkOrganizationMemberUsageLimit,
|
||||
checkServerSideUsageLimits,
|
||||
checkUsageStatus,
|
||||
} from '@/lib/billing/calculations/usage-monitor'
|
||||
|
||||
@@ -73,7 +68,6 @@ describe('checkUsageStatus', () => {
|
||||
ledgerUsage: 125,
|
||||
refreshConsumed: 25,
|
||||
})
|
||||
mockGetOrgMemberRefreshBounds.mockResolvedValue({})
|
||||
})
|
||||
|
||||
it('reads reporting-period organization usage without loading the member roster', async () => {
|
||||
@@ -109,7 +103,6 @@ describe('checkUsageStatus', () => {
|
||||
{ type: 'organization', id: 'org-1' },
|
||||
billingPeriod
|
||||
)
|
||||
expect(mockGetPooledOrgCurrentPeriodCost).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reads paid personal ledger usage and refresh from one snapshot', async () => {
|
||||
@@ -123,17 +116,14 @@ describe('checkUsageStatus', () => {
|
||||
periodStart,
|
||||
periodEnd,
|
||||
}
|
||||
dbChainMockFns.limit.mockResolvedValueOnce([{ currentPeriodCost: '20' }])
|
||||
|
||||
await expect(checkUsageStatus('user-1', subscription)).resolves.toMatchObject({
|
||||
currentUsage: 120,
|
||||
currentUsage: 100,
|
||||
scope: 'user',
|
||||
})
|
||||
|
||||
expect(mockComputeBillingPeriodUsageWithDailyRefresh).toHaveBeenCalledWith({
|
||||
billingEntity: { type: 'user', id: 'user-1' },
|
||||
billingPeriod: { start: periodStart, end: periodEnd },
|
||||
userIds: ['user-1'],
|
||||
refreshPeriodStart: periodStart,
|
||||
refreshPeriodEnd: periodEnd,
|
||||
planDollars: 20,
|
||||
@@ -152,7 +142,6 @@ describe('checkUsageStatus', () => {
|
||||
periodStart,
|
||||
periodEnd,
|
||||
}
|
||||
dbChainMockFns.limit.mockResolvedValueOnce([{ currentPeriodCost: '0' }])
|
||||
mockComputeBillingPeriodUsageWithDailyRefresh.mockResolvedValueOnce({
|
||||
ledgerUsage: -1,
|
||||
refreshConsumed: 1,
|
||||
@@ -175,10 +164,8 @@ describe('checkUsageStatus', () => {
|
||||
periodStart,
|
||||
periodEnd,
|
||||
}
|
||||
dbChainMockFns.limit.mockResolvedValueOnce([{ currentPeriodCost: '20' }])
|
||||
|
||||
await expect(checkUsageStatus('user-1', subscription)).resolves.toMatchObject({
|
||||
currentUsage: 145,
|
||||
currentUsage: 125,
|
||||
scope: 'user',
|
||||
})
|
||||
|
||||
@@ -200,7 +187,6 @@ describe('checkUsageStatus', () => {
|
||||
periodStart,
|
||||
periodEnd,
|
||||
}
|
||||
dbChainMockFns.limit.mockResolvedValueOnce([{ currentPeriodCost: '0' }])
|
||||
mockGetBillingPeriodUsageCost.mockResolvedValueOnce(-1)
|
||||
|
||||
await expect(checkUsageStatus('user-1', subscription)).resolves.toMatchObject({
|
||||
@@ -211,10 +197,9 @@ describe('checkUsageStatus', () => {
|
||||
expect(mockComputeBillingPeriodUsageWithDailyRefresh).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('combines paid organization ledger usage with bounded member refresh', async () => {
|
||||
it('combines paid organization ledger usage with entity-scoped refresh — no roster read', async () => {
|
||||
const periodStart = new Date('2026-06-01T00:00:00.000Z')
|
||||
const periodEnd = new Date('2026-07-01T00:00:00.000Z')
|
||||
const userStart = new Date('2026-06-10T00:00:00.000Z')
|
||||
const subscription = {
|
||||
referenceId: 'org-1',
|
||||
plan: 'team',
|
||||
@@ -223,22 +208,19 @@ describe('checkUsageStatus', () => {
|
||||
periodStart,
|
||||
periodEnd,
|
||||
}
|
||||
mockGetPooledOrgCurrentPeriodCost.mockResolvedValue({
|
||||
memberIds: ['user-1', 'user-2'],
|
||||
currentPeriodCost: 20,
|
||||
})
|
||||
mockGetOrgMemberRefreshBounds.mockResolvedValue({ 'user-2': { userStart } })
|
||||
mockComputeBillingPeriodUsageWithDailyRefresh.mockResolvedValue({
|
||||
ledgerUsage: 100,
|
||||
refreshConsumed: 10,
|
||||
})
|
||||
|
||||
await expect(checkUsageStatus('user-1', subscription)).resolves.toMatchObject({
|
||||
currentUsage: 110,
|
||||
currentUsage: 90,
|
||||
scope: 'organization',
|
||||
organizationId: 'org-1',
|
||||
})
|
||||
|
||||
// Refresh is scoped by the entity stamps alone, so departed members'
|
||||
// org-attributed rows participate identically to current members'.
|
||||
expect(mockComputeBillingPeriodUsageWithDailyRefresh).toHaveBeenCalledWith({
|
||||
billingEntity: { type: 'organization', id: 'org-1' },
|
||||
billingPeriod: expect.objectContaining({
|
||||
@@ -246,37 +228,45 @@ describe('checkUsageStatus', () => {
|
||||
end: periodEnd,
|
||||
source: 'stripe',
|
||||
}),
|
||||
userIds: ['user-1', 'user-2'],
|
||||
refreshPeriodStart: periodStart,
|
||||
refreshPeriodEnd: periodEnd,
|
||||
planDollars: expect.any(Number),
|
||||
seats: 2,
|
||||
userBounds: { 'user-2': { userStart } },
|
||||
})
|
||||
expect(mockGetBillingPeriodUsageCost).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('returns ledger usage without refresh when an organization has no members', async () => {
|
||||
const periodStart = new Date('2026-06-01T00:00:00.000Z')
|
||||
const periodEnd = new Date('2026-07-01T00:00:00.000Z')
|
||||
describe('checkServerSideUsageLimits', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
resetDbChainMock()
|
||||
setEnvFlags({ isHosted: true, isBillingEnabled: true })
|
||||
mockGetBillingPeriodUsageCost.mockResolvedValue(125)
|
||||
})
|
||||
|
||||
it('keeps blocked accounts blocked while reporting their real ledger usage', async () => {
|
||||
dbChainMockFns.limit.mockResolvedValueOnce([{ blocked: true, blockedReason: 'payment_failed' }])
|
||||
const subscription = {
|
||||
referenceId: 'org-1',
|
||||
plan: 'team',
|
||||
referenceId: 'user-1',
|
||||
plan: 'pro',
|
||||
status: 'active',
|
||||
seats: 1,
|
||||
periodStart,
|
||||
periodEnd,
|
||||
periodStart: new Date('2026-06-01T00:00:00.000Z'),
|
||||
periodEnd: new Date('2026-07-01T00:00:00.000Z'),
|
||||
}
|
||||
mockGetPooledOrgCurrentPeriodCost.mockResolvedValue({ memberIds: [], currentPeriodCost: 0 })
|
||||
|
||||
await expect(checkUsageStatus('user-1', subscription)).resolves.toMatchObject({
|
||||
currentUsage: 125,
|
||||
scope: 'organization',
|
||||
})
|
||||
const result = await checkServerSideUsageLimits('user-1', subscription)
|
||||
|
||||
expect(mockGetBillingPeriodUsageCost).toHaveBeenCalledTimes(1)
|
||||
expect(mockComputeBillingPeriodUsageWithDailyRefresh).not.toHaveBeenCalled()
|
||||
expect(mockGetOrgMemberRefreshBounds).not.toHaveBeenCalled()
|
||||
expect(result).toMatchObject({ isExceeded: true, currentUsage: 125, limit: 0 })
|
||||
expect(result.message).toBeTruthy()
|
||||
expect(mockGetBillingPeriodUsageCost).toHaveBeenCalledWith(
|
||||
{ type: 'user', id: 'user-1' },
|
||||
expect.objectContaining({
|
||||
start: subscription.periodStart,
|
||||
end: subscription.periodEnd,
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -7,11 +7,7 @@ import { isOrganizationBillingBlocked } from '@/lib/billing/core/access'
|
||||
import { defaultBillingPeriod } from '@/lib/billing/core/billing-period'
|
||||
import { getHighestPrioritySubscription } from '@/lib/billing/core/plan'
|
||||
import { resolveSubscriptionUsagePeriod } from '@/lib/billing/core/reporting-period'
|
||||
import {
|
||||
getPooledOrgCurrentPeriodCost,
|
||||
getUserUsageLimit,
|
||||
type UsageLimitSubscription,
|
||||
} from '@/lib/billing/core/usage'
|
||||
import { getUserUsageLimit, type UsageLimitSubscription } from '@/lib/billing/core/usage'
|
||||
import {
|
||||
type BillingContext,
|
||||
type BillingEntity,
|
||||
@@ -19,17 +15,13 @@ import {
|
||||
type UsageQueryPeriod,
|
||||
} from '@/lib/billing/core/usage-log'
|
||||
import { dollarsToCredits } from '@/lib/billing/credits/conversion'
|
||||
import {
|
||||
computeBillingPeriodUsageWithDailyRefresh,
|
||||
getOrgMemberRefreshBounds,
|
||||
} from '@/lib/billing/credits/daily-refresh'
|
||||
import { computeBillingPeriodUsageWithDailyRefresh } from '@/lib/billing/credits/daily-refresh'
|
||||
import {
|
||||
getOrgMemberUsageForBillingPeriod,
|
||||
getOrgMemberUsageLimit,
|
||||
} from '@/lib/billing/organizations/member-limits'
|
||||
import { getPlanTierDollars, isPaid } from '@/lib/billing/plan-helpers'
|
||||
import { isOrgScopedSubscription } from '@/lib/billing/subscriptions/utils'
|
||||
import { toDecimal, toNumber } from '@/lib/billing/utils/decimal'
|
||||
import { isBillingEnabled, isHosted } from '@/lib/core/config/env-flags'
|
||||
|
||||
const logger = createLogger('UsageMonitor')
|
||||
@@ -64,49 +56,26 @@ async function computePooledOrgUsage(
|
||||
anchorDate: null,
|
||||
interval: null,
|
||||
}
|
||||
if (billingPeriod.source === 'reporting') {
|
||||
const ledgerUsage = await getBillingPeriodUsageCost(
|
||||
{ type: 'organization', id: organizationId },
|
||||
billingPeriod
|
||||
)
|
||||
return ledgerUsage
|
||||
}
|
||||
|
||||
const { memberIds, currentPeriodCost } = await getPooledOrgCurrentPeriodCost(organizationId)
|
||||
if (memberIds.length === 0) {
|
||||
return getBillingPeriodUsageCost({ type: 'organization', id: organizationId }, billingPeriod)
|
||||
}
|
||||
|
||||
if (!isPaid(sub.plan) || !sub.periodStart) {
|
||||
const ledgerUsage = await getBillingPeriodUsageCost(
|
||||
{ type: 'organization', id: organizationId },
|
||||
billingPeriod
|
||||
)
|
||||
return currentPeriodCost + ledgerUsage
|
||||
return getBillingPeriodUsageCost({ type: 'organization', id: organizationId }, billingPeriod)
|
||||
}
|
||||
|
||||
const planDollars = getPlanTierDollars(sub.plan)
|
||||
if (planDollars <= 0) {
|
||||
const ledgerUsage = await getBillingPeriodUsageCost(
|
||||
{ type: 'organization', id: organizationId },
|
||||
billingPeriod
|
||||
)
|
||||
return currentPeriodCost + ledgerUsage
|
||||
return getBillingPeriodUsageCost({ type: 'organization', id: organizationId }, billingPeriod)
|
||||
}
|
||||
|
||||
const userBounds = await getOrgMemberRefreshBounds(organizationId, sub.periodStart)
|
||||
const { ledgerUsage, refreshConsumed } = await computeBillingPeriodUsageWithDailyRefresh({
|
||||
billingEntity: { type: 'organization', id: organizationId },
|
||||
billingPeriod,
|
||||
userIds: memberIds,
|
||||
refreshPeriodStart: sub.periodStart,
|
||||
refreshPeriodEnd: sub.periodEnd ?? null,
|
||||
planDollars,
|
||||
seats: sub.seats || 1,
|
||||
userBounds: Object.keys(userBounds).length > 0 ? userBounds : undefined,
|
||||
})
|
||||
|
||||
return Math.max(0, currentPeriodCost + ledgerUsage - refreshConsumed)
|
||||
return Math.max(0, ledgerUsage - refreshConsumed)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -120,9 +89,11 @@ export async function checkUsageStatus(
|
||||
): Promise<UsageData> {
|
||||
try {
|
||||
if (!isBillingEnabled) {
|
||||
const statsRecords = await db.select().from(userStats).where(eq(userStats.userId, userId))
|
||||
const currentUsage =
|
||||
statsRecords.length > 0 ? toNumber(toDecimal(statsRecords[0].currentPeriodCost)) : 0
|
||||
// Self-hosted display: lifetime ledger over the open default window.
|
||||
const currentUsage = await getBillingPeriodUsageCost(
|
||||
{ type: 'user', id: userId },
|
||||
{ ...defaultBillingPeriod(), source: 'default' }
|
||||
)
|
||||
|
||||
return {
|
||||
percentUsed: Math.min((currentUsage / 1000) * 100, 100),
|
||||
@@ -156,25 +127,6 @@ export async function checkUsageStatus(
|
||||
return buildUsageData({ currentUsage, limit, scope, organizationId })
|
||||
}
|
||||
|
||||
const statsRecords = await db
|
||||
.select()
|
||||
.from(userStats)
|
||||
.where(eq(userStats.userId, userId))
|
||||
.limit(1)
|
||||
|
||||
if (statsRecords.length === 0) {
|
||||
logger.info('No usage stats found for user', { userId, limit })
|
||||
return {
|
||||
percentUsed: 0,
|
||||
isWarning: false,
|
||||
isExceeded: false,
|
||||
currentUsage: 0,
|
||||
limit,
|
||||
scope: 'user',
|
||||
organizationId: null,
|
||||
}
|
||||
}
|
||||
|
||||
const billingPeriod =
|
||||
preloadedBillingContext?.billingPeriod ??
|
||||
(sub?.periodStart && sub.periodEnd
|
||||
@@ -189,7 +141,6 @@ export async function checkUsageStatus(
|
||||
const usage = await computeBillingPeriodUsageWithDailyRefresh({
|
||||
billingEntity: { type: 'user', id: userId },
|
||||
billingPeriod,
|
||||
userIds: [userId],
|
||||
refreshPeriodStart: sub.periodStart,
|
||||
refreshPeriodEnd: sub.periodEnd ?? null,
|
||||
planDollars,
|
||||
@@ -203,8 +154,7 @@ export async function checkUsageStatus(
|
||||
} else {
|
||||
ledgerUsage = await getBillingPeriodUsageCost({ type: 'user', id: userId }, billingPeriod)
|
||||
}
|
||||
const usageBeforeRefresh =
|
||||
toNumber(toDecimal(statsRecords[0].currentPeriodCost)) + ledgerUsage - refreshConsumed
|
||||
const usageBeforeRefresh = ledgerUsage - refreshConsumed
|
||||
const currentUsage = appliedDailyRefresh ? Math.max(0, usageBeforeRefresh) : usageBeforeRefresh
|
||||
|
||||
return buildUsageData({ currentUsage, limit, scope, organizationId })
|
||||
@@ -364,16 +314,22 @@ export async function checkServerSideUsageLimits(
|
||||
|
||||
logger.info('Server-side checking usage limits for user', { userId })
|
||||
|
||||
const stats = await db
|
||||
.select({ current: userStats.currentPeriodCost })
|
||||
.from(userStats)
|
||||
.where(eq(userStats.userId, userId))
|
||||
.limit(1)
|
||||
|
||||
const currentUsage = stats.length > 0 ? toNumber(toDecimal(stats[0].current)) : 0
|
||||
|
||||
const blocked = await checkBillingBlocked(userId)
|
||||
if (blocked.blocked) {
|
||||
// Enforcement stays blocked, but surfaced usage must be the real ledger
|
||||
// value — `/api/users/me/usage-limits` exposes it as `currentPeriodCost`.
|
||||
const sub =
|
||||
preloadedSubscription !== undefined
|
||||
? preloadedSubscription
|
||||
: await getHighestPrioritySubscription(userId)
|
||||
const subIsOrgScoped = isOrgScopedSubscription(sub, userId)
|
||||
const billingEntity: BillingEntity =
|
||||
subIsOrgScoped && sub
|
||||
? { type: 'organization', id: sub.referenceId }
|
||||
: { type: 'user', id: userId }
|
||||
const billingPeriod = preloadedBillingContext?.billingPeriod ??
|
||||
resolveSubscriptionUsagePeriod(sub) ?? { ...defaultBillingPeriod(), source: 'default' }
|
||||
const currentUsage = await getBillingPeriodUsageCost(billingEntity, billingPeriod)
|
||||
return { isExceeded: true, currentUsage, limit: 0, message: blocked.message }
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { dbChainMock, dbChainMockFns } from '@sim/testing'
|
||||
import { dbChainMock, dbChainMockFns, queueTableRows, schemaMock } from '@sim/testing'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const {
|
||||
@@ -42,18 +42,17 @@ vi.mock('@/lib/billing/core/usage-log', () => ({
|
||||
|
||||
vi.mock('@/lib/billing/credits/daily-refresh', () => ({
|
||||
computeDailyRefreshConsumed: mockComputeDailyRefreshConsumed,
|
||||
getOrgMemberRefreshBounds: vi.fn(),
|
||||
}))
|
||||
|
||||
import { getPersonalBillingSummary } from '@/lib/billing/core/billing'
|
||||
import { calculateSubscriptionOverage, getPersonalBillingSummary } from '@/lib/billing/core/billing'
|
||||
|
||||
describe('getPersonalBillingSummary', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockEnsureUserStatsExists.mockResolvedValue(undefined)
|
||||
mockResolveBillingInterval.mockReturnValue('year')
|
||||
mockComputeDailyRefreshConsumed.mockResolvedValue(3)
|
||||
mockGetBillingPeriodUsageCostWithSourceSubset.mockResolvedValue({ total: 2, subset: 1 })
|
||||
mockComputeDailyRefreshConsumed.mockResolvedValue(1)
|
||||
mockGetBillingPeriodUsageCostWithSourceSubset.mockResolvedValue({ total: 4, subset: 1 })
|
||||
mockGetHighestPriorityPersonalSubscription.mockResolvedValue({
|
||||
id: 'personal-sub',
|
||||
referenceId: 'viewer-a',
|
||||
@@ -74,12 +73,8 @@ describe('getPersonalBillingSummary', () => {
|
||||
})
|
||||
dbChainMockFns.limit.mockResolvedValueOnce([
|
||||
{
|
||||
currentPeriodCost: '10',
|
||||
currentUsageLimit: '30',
|
||||
lastPeriodCost: '6',
|
||||
proPeriodCostSnapshot: '4',
|
||||
proPeriodCostSnapshotAt: new Date('2026-07-10T00:00:00.000Z'),
|
||||
currentPeriodCopilotCost: '5',
|
||||
lastPeriodCopilotCost: '2',
|
||||
creditBalance: '7',
|
||||
billingBlocked: true,
|
||||
@@ -117,10 +112,50 @@ describe('getPersonalBillingSummary', () => {
|
||||
})
|
||||
expect(mockComputeDailyRefreshConsumed).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
periodEnd: new Date('2026-07-10T00:00:00.000Z'),
|
||||
periodEnd: new Date('2026-08-01T00:00:00.000Z'),
|
||||
billingEntity: { type: 'user', id: 'viewer-a' },
|
||||
}),
|
||||
dbChainMock.db
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('calculateSubscriptionOverage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockComputeDailyRefreshConsumed.mockResolvedValue(0)
|
||||
})
|
||||
|
||||
it('bills the pooled org ledger with entity-scoped refresh — no roster read', async () => {
|
||||
queueTableRows(schemaMock.organization, [{ id: 'org-1' }]) // isSubscriptionOrgScoped
|
||||
// Pooled ledger sum includes departed members' org-stamped rows.
|
||||
mockGetBillingPeriodUsageCost.mockResolvedValue(160)
|
||||
|
||||
const overage = await calculateSubscriptionOverage({
|
||||
id: 'sub-1',
|
||||
plan: 'team',
|
||||
referenceId: 'org-1',
|
||||
seats: 2,
|
||||
periodStart: new Date('2026-07-01T00:00:00.000Z'),
|
||||
periodEnd: new Date('2026-08-01T00:00:00.000Z'),
|
||||
})
|
||||
|
||||
expect(mockGetBillingPeriodUsageCost).toHaveBeenCalledWith(
|
||||
{ type: 'organization', id: 'org-1' },
|
||||
{
|
||||
start: new Date('2026-07-01T00:00:00.000Z'),
|
||||
end: new Date('2026-08-01T00:00:00.000Z'),
|
||||
}
|
||||
)
|
||||
// Refresh is scoped by the same entity stamps as the ledger sum — no
|
||||
// actor list, so departed members' rows participate identically.
|
||||
expect(mockComputeDailyRefreshConsumed).toHaveBeenCalledWith({
|
||||
billingEntity: { type: 'organization', id: 'org-1' },
|
||||
periodStart: new Date('2026-07-01T00:00:00.000Z'),
|
||||
periodEnd: new Date('2026-08-01T00:00:00.000Z'),
|
||||
planDollars: 40,
|
||||
seats: 2,
|
||||
})
|
||||
expect(overage).toBe(80)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { db } from '@sim/db'
|
||||
import { member, organization, subscription, userStats } from '@sim/db/schema'
|
||||
import { organization, subscription, userStats } from '@sim/db/schema'
|
||||
import { and, desc, eq, inArray } from 'drizzle-orm'
|
||||
import { defaultBillingPeriod } from '@/lib/billing/core/billing-period'
|
||||
import {
|
||||
@@ -12,10 +12,7 @@ import {
|
||||
getBillingPeriodUsageCost,
|
||||
getBillingPeriodUsageCostWithSourceSubset,
|
||||
} from '@/lib/billing/core/usage-log'
|
||||
import {
|
||||
computeDailyRefreshConsumed,
|
||||
getOrgMemberRefreshBounds,
|
||||
} from '@/lib/billing/credits/daily-refresh'
|
||||
import { computeDailyRefreshConsumed } from '@/lib/billing/credits/daily-refresh'
|
||||
import { getPlanTierDollars, isEnterprise, isPaid, isPro, isTeam } from '@/lib/billing/plan-helpers'
|
||||
import {
|
||||
ENTITLED_SUBSCRIPTION_STATUSES,
|
||||
@@ -113,70 +110,11 @@ export async function isSubscriptionOrgScoped(sub: { referenceId: string }): Pro
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate raw pooled stats for all members of an organization in a single
|
||||
* query. Used by org-scoped summary and overage calculations so we don't
|
||||
* call `getUserUsageData` per-member — that helper now returns the entire
|
||||
* pool for org-scoped subs, which would N-times-count the usage.
|
||||
*
|
||||
* The `currentPeriodCost` sum here is semantically identical to
|
||||
* `getPooledOrgCurrentPeriodCost` (same `LEFT JOIN` + `toDecimal`
|
||||
* null handling); this helper bundles the copilot fields in the same
|
||||
* round-trip. Never fall back to lifetime `totalCost` on nulls — the
|
||||
* column is `NOT NULL DEFAULT '0'` and mixing scopes would break
|
||||
* current-period billing math.
|
||||
*/
|
||||
async function aggregateOrgMemberStats(
|
||||
organizationId: string,
|
||||
executor: DbClient = db
|
||||
): Promise<{
|
||||
memberIds: string[]
|
||||
currentPeriodCost: number
|
||||
currentPeriodCopilotCost: number
|
||||
lastPeriodCopilotCost: number
|
||||
}> {
|
||||
const rows = await executor
|
||||
.select({
|
||||
userId: member.userId,
|
||||
currentPeriodCost: userStats.currentPeriodCost,
|
||||
currentPeriodCopilotCost: userStats.currentPeriodCopilotCost,
|
||||
lastPeriodCopilotCost: userStats.lastPeriodCopilotCost,
|
||||
})
|
||||
.from(member)
|
||||
.leftJoin(userStats, eq(member.userId, userStats.userId))
|
||||
.where(eq(member.organizationId, organizationId))
|
||||
|
||||
let currentPeriodCost = new Decimal(0)
|
||||
// Copilot baseline (copilot source). All copilot-family usage (incl. MCP) lives
|
||||
// in usage_log and is added via the copilot ledger by callers — not a baseline.
|
||||
let currentPeriodCopilotCost = new Decimal(0)
|
||||
let lastPeriodCopilotCost = new Decimal(0)
|
||||
const memberIds: string[] = []
|
||||
|
||||
for (const row of rows) {
|
||||
memberIds.push(row.userId)
|
||||
currentPeriodCost = currentPeriodCost.plus(toDecimal(row.currentPeriodCost))
|
||||
currentPeriodCopilotCost = currentPeriodCopilotCost.plus(
|
||||
toDecimal(row.currentPeriodCopilotCost)
|
||||
)
|
||||
lastPeriodCopilotCost = lastPeriodCopilotCost.plus(toDecimal(row.lastPeriodCopilotCost))
|
||||
}
|
||||
|
||||
return {
|
||||
memberIds,
|
||||
currentPeriodCost: toNumber(currentPeriodCost),
|
||||
currentPeriodCopilotCost: toNumber(currentPeriodCopilotCost),
|
||||
lastPeriodCopilotCost: toNumber(lastPeriodCopilotCost),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute an org's overage amount from already-fetched pool/departed
|
||||
* inputs. Internally performs one daily-refresh DB read to subtract
|
||||
* refresh credits; callers are expected to have already loaded the
|
||||
* pooled `currentPeriodCost` and `departedMemberUsage` (threshold
|
||||
* billing passes lock-held values; `calculateSubscriptionOverage`
|
||||
* passes lockless values from `aggregateOrgMemberStats`). Both
|
||||
* callers route through this to keep the overage math in one place.
|
||||
* Compute an org's overage amount from an already-fetched pooled ledger sum.
|
||||
* Internally performs one daily-refresh DB read to subtract refresh credits;
|
||||
* callers pass the org-attributed ledger usage for the period (threshold
|
||||
* billing passes the current period; cycle close passes the closed period).
|
||||
* All callers route through this to keep the overage math in one place.
|
||||
*/
|
||||
export async function computeOrgOverageAmount(params: {
|
||||
plan: string | null
|
||||
@@ -184,29 +122,24 @@ export async function computeOrgOverageAmount(params: {
|
||||
periodStart: Date | null
|
||||
periodEnd: Date | null
|
||||
organizationId: string
|
||||
pooledCurrentPeriodCost: number
|
||||
departedMemberUsage: number
|
||||
memberIds: string[]
|
||||
pooledLedgerUsage: number
|
||||
}): Promise<{
|
||||
effectiveUsage: number
|
||||
baseSubscriptionAmount: number
|
||||
dailyRefreshDeduction: number
|
||||
totalOverage: number
|
||||
}> {
|
||||
const totalUsage = params.pooledCurrentPeriodCost + params.departedMemberUsage
|
||||
const totalUsage = params.pooledLedgerUsage
|
||||
|
||||
let dailyRefreshDeduction = 0
|
||||
const planDollars = getPlanTierDollars(params.plan)
|
||||
if (planDollars > 0 && params.periodStart && params.memberIds.length > 0) {
|
||||
const userBounds = await getOrgMemberRefreshBounds(params.organizationId, params.periodStart)
|
||||
if (planDollars > 0 && params.periodStart) {
|
||||
dailyRefreshDeduction = await computeDailyRefreshConsumed({
|
||||
userIds: params.memberIds,
|
||||
billingEntity: { type: 'organization', id: params.organizationId },
|
||||
periodStart: params.periodStart,
|
||||
periodEnd: params.periodEnd ?? null,
|
||||
planDollars,
|
||||
seats: params.seats || 1,
|
||||
userBounds: Object.keys(userBounds).length > 0 ? userBounds : undefined,
|
||||
billingEntity: { type: 'organization', id: params.organizationId },
|
||||
})
|
||||
}
|
||||
|
||||
@@ -244,7 +177,6 @@ export async function calculateSubscriptionOverage(sub: {
|
||||
const isOrgScoped = await isSubscriptionOrgScoped(sub)
|
||||
|
||||
if (isOrgScoped) {
|
||||
const pooled = await aggregateOrgMemberStats(sub.referenceId)
|
||||
const ledgerUsage =
|
||||
sub.periodStart && sub.periodEnd
|
||||
? await getBillingPeriodUsageCost(
|
||||
@@ -253,24 +185,13 @@ export async function calculateSubscriptionOverage(sub: {
|
||||
)
|
||||
: 0
|
||||
|
||||
const orgData = await db
|
||||
.select({ departedMemberUsage: organization.departedMemberUsage })
|
||||
.from(organization)
|
||||
.where(eq(organization.id, sub.referenceId))
|
||||
.limit(1)
|
||||
|
||||
const departedMemberUsage =
|
||||
orgData.length > 0 ? toNumber(toDecimal(orgData[0].departedMemberUsage)) : 0
|
||||
|
||||
const { totalOverage, effectiveUsage, baseSubscriptionAmount } = await computeOrgOverageAmount({
|
||||
plan: sub.plan,
|
||||
seats: sub.seats ?? null,
|
||||
periodStart: sub.periodStart ?? null,
|
||||
periodEnd: sub.periodEnd ?? null,
|
||||
organizationId: sub.referenceId,
|
||||
pooledCurrentPeriodCost: pooled.currentPeriodCost + ledgerUsage,
|
||||
departedMemberUsage,
|
||||
memberIds: pooled.memberIds,
|
||||
pooledLedgerUsage: ledgerUsage,
|
||||
})
|
||||
|
||||
totalOverageDecimal = toDecimal(totalOverage)
|
||||
@@ -278,33 +199,18 @@ export async function calculateSubscriptionOverage(sub: {
|
||||
logger.info('Calculated org-scoped overage', {
|
||||
subscriptionId: sub.id,
|
||||
plan: sub.plan,
|
||||
currentMemberUsage: pooled.currentPeriodCost + ledgerUsage,
|
||||
departedMemberUsage,
|
||||
ledgerUsage,
|
||||
totalUsage: pooled.currentPeriodCost + ledgerUsage + departedMemberUsage,
|
||||
effectiveUsage,
|
||||
baseSubscriptionAmount,
|
||||
totalOverage,
|
||||
})
|
||||
} else if (isPro(sub.plan)) {
|
||||
// Read user_stats directly (not via `getUserUsageData`). Priority
|
||||
// lookup prefers org over personal within tier, so during a
|
||||
// cancel-at-period-end grace window it would return pooled org usage
|
||||
// instead of this user's personal period — overbilling the final
|
||||
// personal Pro invoice.
|
||||
const [statsRow] = await db
|
||||
.select({
|
||||
currentPeriodCost: userStats.currentPeriodCost,
|
||||
proPeriodCostSnapshot: userStats.proPeriodCostSnapshot,
|
||||
proPeriodCostSnapshotAt: userStats.proPeriodCostSnapshotAt,
|
||||
})
|
||||
.from(userStats)
|
||||
.where(eq(userStats.userId, sub.referenceId))
|
||||
.limit(1)
|
||||
|
||||
const personalCurrentUsage = statsRow ? toNumber(toDecimal(statsRow.currentPeriodCost)) : 0
|
||||
const snapshotUsage = statsRow ? toNumber(toDecimal(statsRow.proPeriodCostSnapshot)) : 0
|
||||
const snapshotAt = statsRow?.proPeriodCostSnapshotAt ?? null
|
||||
} else {
|
||||
// Ledger sums are read for the exact reference user (not via
|
||||
// `getUserUsageData`). Priority lookup prefers org over personal within
|
||||
// tier, so during a cancel-at-period-end grace window it would return
|
||||
// pooled org usage instead of this user's personal period — overbilling
|
||||
// the final personal invoice. Ledger entity stamps already attribute
|
||||
// post-org-join usage to the org, so the personal sum excludes it.
|
||||
const ledgerUsage =
|
||||
sub.periodStart && sub.periodEnd
|
||||
? await getBillingPeriodUsageCost(
|
||||
@@ -313,82 +219,30 @@ export async function calculateSubscriptionOverage(sub: {
|
||||
)
|
||||
: 0
|
||||
|
||||
const joinedOrgMidCycle = snapshotAt !== null || snapshotUsage > 0
|
||||
const totalProUsageDecimal = joinedOrgMidCycle
|
||||
? toDecimal(snapshotUsage).plus(ledgerUsage)
|
||||
: toDecimal(personalCurrentUsage).plus(ledgerUsage)
|
||||
|
||||
if (joinedOrgMidCycle) {
|
||||
logger.info('Billing personal Pro only for pre-join usage (user joined org mid-cycle)', {
|
||||
userId: sub.referenceId,
|
||||
preJoinUsage: snapshotUsage,
|
||||
postJoinUsageOnMemberRow: personalCurrentUsage,
|
||||
snapshotAt: snapshotAt?.toISOString() ?? null,
|
||||
subscriptionId: sub.id,
|
||||
})
|
||||
}
|
||||
|
||||
let dailyRefreshDeduction = 0
|
||||
const planDollars = getPlanTierDollars(sub.plan)
|
||||
if (planDollars > 0 && sub.periodStart) {
|
||||
// If the user joined an org mid-cycle, their usageLog rows after
|
||||
// `snapshotAt` belong to the org's pooled refresh. Cap refresh
|
||||
// to [periodStart, snapshotAt) so post-join refresh isn't
|
||||
// deducted from pre-join personal Pro usage.
|
||||
const refreshCap = joinedOrgMidCycle && snapshotAt ? snapshotAt : (sub.periodEnd ?? null)
|
||||
dailyRefreshDeduction = await computeDailyRefreshConsumed({
|
||||
userIds: [sub.referenceId],
|
||||
periodStart: sub.periodStart,
|
||||
periodEnd: refreshCap,
|
||||
planDollars,
|
||||
billingEntity: { type: 'user', id: sub.referenceId },
|
||||
})
|
||||
if (isPro(sub.plan)) {
|
||||
const planDollars = getPlanTierDollars(sub.plan)
|
||||
if (planDollars > 0 && sub.periodStart) {
|
||||
dailyRefreshDeduction = await computeDailyRefreshConsumed({
|
||||
billingEntity: { type: 'user', id: sub.referenceId },
|
||||
periodStart: sub.periodStart,
|
||||
periodEnd: sub.periodEnd ?? null,
|
||||
planDollars,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const effectiveUsageDecimal = Decimal.max(
|
||||
0,
|
||||
totalProUsageDecimal.minus(toDecimal(dailyRefreshDeduction))
|
||||
)
|
||||
const { basePrice } = getPlanPricing(sub.plan ?? '')
|
||||
totalOverageDecimal = Decimal.max(0, effectiveUsageDecimal.minus(basePrice))
|
||||
|
||||
logger.info('Calculated personal pro overage', {
|
||||
subscriptionId: sub.id,
|
||||
joinedOrgMidCycle,
|
||||
personalCurrentUsage,
|
||||
snapshot: snapshotUsage,
|
||||
ledgerUsage,
|
||||
billedUsage: toNumber(totalProUsageDecimal),
|
||||
dailyRefreshDeduction,
|
||||
basePrice,
|
||||
totalOverage: toNumber(totalOverageDecimal),
|
||||
})
|
||||
} else {
|
||||
// Free or unknown plan. Same direct-read rationale as the Pro branch.
|
||||
const [statsRow] = await db
|
||||
.select({ currentPeriodCost: userStats.currentPeriodCost })
|
||||
.from(userStats)
|
||||
.where(eq(userStats.userId, sub.referenceId))
|
||||
.limit(1)
|
||||
const personalCurrentUsage = statsRow ? toNumber(toDecimal(statsRow.currentPeriodCost)) : 0
|
||||
const ledgerUsage =
|
||||
sub.periodStart && sub.periodEnd
|
||||
? await getBillingPeriodUsageCost(
|
||||
{ type: 'user', id: sub.referenceId },
|
||||
{ start: sub.periodStart, end: sub.periodEnd }
|
||||
)
|
||||
: 0
|
||||
const { basePrice } = getPlanPricing(sub.plan || 'free')
|
||||
totalOverageDecimal = Decimal.max(
|
||||
0,
|
||||
toDecimal(personalCurrentUsage).plus(ledgerUsage).minus(basePrice)
|
||||
toDecimal(ledgerUsage).minus(toDecimal(dailyRefreshDeduction)).minus(basePrice)
|
||||
)
|
||||
|
||||
logger.info('Calculated overage for plan', {
|
||||
logger.info('Calculated personal overage', {
|
||||
subscriptionId: sub.id,
|
||||
plan: sub.plan || 'free',
|
||||
usage: personalCurrentUsage + ledgerUsage,
|
||||
ledgerUsage,
|
||||
dailyRefreshDeduction,
|
||||
basePrice,
|
||||
totalOverage: toNumber(totalOverageDecimal),
|
||||
})
|
||||
@@ -410,12 +264,8 @@ export async function getPersonalBillingSummary(userId: string, executor: DbClie
|
||||
getHighestPriorityPersonalSubscription(userId, { executor }),
|
||||
db
|
||||
.select({
|
||||
currentPeriodCost: userStats.currentPeriodCost,
|
||||
currentUsageLimit: userStats.currentUsageLimit,
|
||||
lastPeriodCost: userStats.lastPeriodCost,
|
||||
proPeriodCostSnapshot: userStats.proPeriodCostSnapshot,
|
||||
proPeriodCostSnapshotAt: userStats.proPeriodCostSnapshotAt,
|
||||
currentPeriodCopilotCost: userStats.currentPeriodCopilotCost,
|
||||
lastPeriodCopilotCost: userStats.lastPeriodCopilotCost,
|
||||
creditBalance: userStats.creditBalance,
|
||||
billingBlocked: userStats.billingBlocked,
|
||||
@@ -444,12 +294,7 @@ export async function getPersonalBillingSummary(userId: string, executor: DbClie
|
||||
executor
|
||||
)
|
||||
|
||||
const hasPersonalUsageSnapshot =
|
||||
Boolean(personalSubscription) && isPro(plan) && stats.proPeriodCostSnapshotAt !== null
|
||||
const personalUsageBaseline = hasPersonalUsageSnapshot
|
||||
? stats.proPeriodCostSnapshot
|
||||
: stats.currentPeriodCost
|
||||
const currentUsage = toDecimal(personalUsageBaseline).plus(ledgerUsage)
|
||||
const currentUsage = toDecimal(ledgerUsage)
|
||||
|
||||
let refreshDeduction = 0
|
||||
if (
|
||||
@@ -462,13 +307,10 @@ export async function getPersonalBillingSummary(userId: string, executor: DbClie
|
||||
if (planDollars > 0) {
|
||||
refreshDeduction = await computeDailyRefreshConsumed(
|
||||
{
|
||||
userIds: [userId],
|
||||
periodStart: personalSubscription.periodStart,
|
||||
periodEnd: hasPersonalUsageSnapshot
|
||||
? stats.proPeriodCostSnapshotAt
|
||||
: (personalSubscription.periodEnd ?? null),
|
||||
planDollars,
|
||||
billingEntity: { type: 'user', id: userId },
|
||||
periodStart: personalSubscription.periodStart,
|
||||
periodEnd: personalSubscription.periodEnd ?? null,
|
||||
planDollars,
|
||||
},
|
||||
executor
|
||||
)
|
||||
@@ -528,9 +370,7 @@ export async function getPersonalBillingSummary(userId: string, executor: DbClie
|
||||
lastPeriodCost: toNumber(toDecimal(stats.lastPeriodCost)),
|
||||
lastPeriodCopilotCost: toNumber(toDecimal(stats.lastPeriodCopilotCost)),
|
||||
daysRemaining,
|
||||
copilotCost:
|
||||
(hasPersonalUsageSnapshot ? 0 : toNumber(toDecimal(stats.currentPeriodCopilotCost))) +
|
||||
copilotLedgerUsage,
|
||||
copilotCost: copilotLedgerUsage,
|
||||
},
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -28,7 +28,7 @@ describe('getOrganizationMemberUsageSnapshot', () => {
|
||||
|
||||
afterEach(() => vi.useRealTimers())
|
||||
|
||||
it('uses the Enterprise reporting window and excludes the legacy baseline', async () => {
|
||||
it('uses the Enterprise reporting window for anchored organizations', async () => {
|
||||
getOrganizationSubscription.mockResolvedValue({
|
||||
plan: 'enterprise',
|
||||
billingInterval: 'year',
|
||||
@@ -46,7 +46,6 @@ describe('getOrganizationMemberUsageSnapshot', () => {
|
||||
start: new Date('2026-01-01T00:00:00.000Z'),
|
||||
end: new Date('2027-01-01T00:00:00.000Z'),
|
||||
})
|
||||
expect(snapshot.includeLegacyBaseline).toBe(false)
|
||||
expect(getBillingPeriodUsageCostByUser).toHaveBeenCalledWith(
|
||||
{ type: 'organization', id: 'org-1' },
|
||||
expect.objectContaining({ source: 'reporting' }),
|
||||
@@ -56,7 +55,7 @@ describe('getOrganizationMemberUsageSnapshot', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('uses Stripe dates and retains the legacy baseline without custom reporting metadata', async () => {
|
||||
it('uses Stripe dates without custom reporting metadata', async () => {
|
||||
const periodStart = new Date('2026-08-01T00:00:00.000Z')
|
||||
const periodEnd = new Date('2026-09-01T00:00:00.000Z')
|
||||
getOrganizationSubscription.mockResolvedValue({
|
||||
@@ -76,6 +75,6 @@ describe('getOrganizationMemberUsageSnapshot', () => {
|
||||
anchorDate: null,
|
||||
interval: 'month',
|
||||
})
|
||||
expect(snapshot.includeLegacyBaseline).toBe(true)
|
||||
expect(snapshot.usageByUser).toEqual(new Map([['user-1', 12.5]]))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
getBillingPeriodUsageCostByUser,
|
||||
type UsageQueryPeriod,
|
||||
} from '@/lib/billing/core/usage-log'
|
||||
import { computeOrganizationDailyRefreshConsumed } from '@/lib/billing/credits/daily-refresh'
|
||||
import { computeDailyRefreshConsumed } from '@/lib/billing/credits/daily-refresh'
|
||||
import { getPlanTierDollars, isEnterprise, isPaid } from '@/lib/billing/plan-helpers'
|
||||
import {
|
||||
getEffectiveSeats,
|
||||
@@ -67,11 +67,10 @@ interface MemberUsageData {
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-member usage_log cost for an org's current billing period, keyed by userId.
|
||||
* `currentPeriodCost` is only a baseline (no longer incremented on the hot path),
|
||||
* so callers add this ledger component to it for each member's real current-period
|
||||
* usage. Pass `period` to reuse an already-fetched subscription window; omit it to
|
||||
* look up the org's subscription here. Returns an empty map when there's no period.
|
||||
* Per-member usage_log cost for an org's current billing period, keyed by
|
||||
* userId — each member's real current-period usage. Pass `period` to reuse an
|
||||
* already-fetched subscription window; omit it to look up the org's
|
||||
* subscription here. Returns an empty map when there's no period.
|
||||
*/
|
||||
export async function getOrgMemberLedgerByUser(
|
||||
organizationId: string,
|
||||
@@ -96,7 +95,6 @@ export async function getOrgMemberLedgerByUser(
|
||||
|
||||
export interface OrganizationMemberUsageSnapshot {
|
||||
billingPeriod: UsageQueryPeriod | null
|
||||
includeLegacyBaseline: boolean
|
||||
usageByUser: Map<string, number>
|
||||
}
|
||||
|
||||
@@ -106,13 +104,9 @@ const MAX_ORGANIZATION_BILLING_MEMBER_LIMIT = 100
|
||||
async function getOrganizationMemberUsageCounts(
|
||||
organizationId: string,
|
||||
billingPeriod: UsageQueryPeriod,
|
||||
includeLegacyBaseline: boolean,
|
||||
executor: DbClient
|
||||
): Promise<{ overLimit: number; nearLimit: number }> {
|
||||
const currentUsage = sql<number>`(
|
||||
${includeLegacyBaseline ? sql`coalesce(${userStats.currentPeriodCost}, 0)` : sql`0`} +
|
||||
coalesce(sum(${usageLog.cost}), 0)
|
||||
)`
|
||||
const currentUsage = sql<number>`coalesce(sum(${usageLog.cost}), 0)`
|
||||
.mapWith(Number)
|
||||
.as('current_usage')
|
||||
const usageLimit = sql<number>`coalesce(${userStats.currentUsageLimit}, ${getFreeTierLimit()})`
|
||||
@@ -140,7 +134,7 @@ async function getOrganizationMemberUsageCounts(
|
||||
)
|
||||
)
|
||||
.where(eq(member.organizationId, organizationId))
|
||||
.groupBy(member.userId, userStats.currentPeriodCost, userStats.currentUsageLimit)
|
||||
.groupBy(member.userId, userStats.currentUsageLimit)
|
||||
.as('organization_member_usage')
|
||||
|
||||
const [counts] = await executor
|
||||
@@ -164,8 +158,7 @@ async function getOrganizationMemberUsageCounts(
|
||||
|
||||
/**
|
||||
* Resolves the organization's usage period once and returns the ledger usage
|
||||
* for only the requested actors. Reporting periods never include the legacy
|
||||
* userStats baseline; Stripe/default periods retain it for compatibility.
|
||||
* for only the requested actors.
|
||||
*/
|
||||
export async function getOrganizationMemberUsageSnapshot(
|
||||
organizationId: string,
|
||||
@@ -179,7 +172,6 @@ export async function getOrganizationMemberUsageSnapshot(
|
||||
const billingPeriod = subscription ? resolveSubscriptionUsagePeriodOrDefault(subscription) : null
|
||||
return {
|
||||
billingPeriod,
|
||||
includeLegacyBaseline: billingPeriod?.source !== 'reporting',
|
||||
usageByUser: billingPeriod
|
||||
? await getOrgMemberLedgerByUser(organizationId, billingPeriod, executor, options.userIds)
|
||||
: new Map(),
|
||||
@@ -218,7 +210,6 @@ export async function getOrganizationBillingData(
|
||||
}
|
||||
|
||||
const billingPeriod = resolveSubscriptionUsagePeriodOrDefault(subscription)
|
||||
const includeLegacyBaseline = billingPeriod?.source !== 'reporting'
|
||||
const limit = Math.min(
|
||||
MAX_ORGANIZATION_BILLING_MEMBER_LIMIT,
|
||||
Math.max(1, memberPage.limit ?? DEFAULT_ORGANIZATION_BILLING_MEMBER_LIMIT)
|
||||
@@ -226,12 +217,8 @@ export async function getOrganizationBillingData(
|
||||
const offset = Math.max(0, memberPage.offset ?? 0)
|
||||
const [memberAggregateRows, membersWithUsage] = await Promise.all([
|
||||
executor
|
||||
.select({
|
||||
total: count(),
|
||||
baseline: sql<string>`coalesce(sum(${userStats.currentPeriodCost}), 0)`,
|
||||
})
|
||||
.select({ total: count() })
|
||||
.from(member)
|
||||
.leftJoin(userStats, eq(member.userId, userStats.userId))
|
||||
.where(eq(member.organizationId, organizationId)),
|
||||
executor
|
||||
.select({
|
||||
@@ -240,7 +227,6 @@ export async function getOrganizationBillingData(
|
||||
userEmail: user.email,
|
||||
role: member.role,
|
||||
joinedAt: member.createdAt,
|
||||
currentPeriodCost: userStats.currentPeriodCost,
|
||||
currentUsageLimit: userStats.currentUsageLimit,
|
||||
})
|
||||
.from(member)
|
||||
@@ -257,9 +243,7 @@ export async function getOrganizationBillingData(
|
||||
: new Map<string, number>()
|
||||
|
||||
const members: MemberUsageData[] = membersWithUsage.map((memberRecord) => {
|
||||
const currentUsage =
|
||||
(includeLegacyBaseline ? Number(memberRecord.currentPeriodCost || 0) : 0) +
|
||||
(usageByUser.get(memberRecord.userId) ?? 0)
|
||||
const currentUsage = usageByUser.get(memberRecord.userId) ?? 0
|
||||
const usageLimit = Number(memberRecord.currentUsageLimit || getFreeTierLimit())
|
||||
const percentUsed = usageLimit > 0 ? (currentUsage / usageLimit) * 100 : 0
|
||||
|
||||
@@ -278,22 +262,21 @@ export async function getOrganizationBillingData(
|
||||
|
||||
const memberAggregate = memberAggregateRows[0]
|
||||
const membersTotal = memberAggregate?.total ?? 0
|
||||
let totalCurrentUsage = includeLegacyBaseline ? Number(memberAggregate?.baseline ?? 0) : 0
|
||||
if (billingPeriod) {
|
||||
totalCurrentUsage += await getBillingPeriodUsageCost(
|
||||
{ type: 'organization', id: subscription.referenceId },
|
||||
billingPeriod,
|
||||
undefined,
|
||||
executor
|
||||
)
|
||||
}
|
||||
let totalCurrentUsage = billingPeriod
|
||||
? await getBillingPeriodUsageCost(
|
||||
{ type: 'organization', id: subscription.referenceId },
|
||||
billingPeriod,
|
||||
undefined,
|
||||
executor
|
||||
)
|
||||
: 0
|
||||
|
||||
if (isPaid(subscription.plan) && subscription.periodStart) {
|
||||
const planDollars = getPlanTierDollars(subscription.plan)
|
||||
if (planDollars > 0) {
|
||||
const refreshConsumed = await computeOrganizationDailyRefreshConsumed(
|
||||
const refreshConsumed = await computeDailyRefreshConsumed(
|
||||
{
|
||||
organizationId: subscription.referenceId,
|
||||
billingEntity: { type: 'organization', id: subscription.referenceId },
|
||||
periodStart: subscription.periodStart,
|
||||
periodEnd: subscription.periodEnd ?? null,
|
||||
planDollars,
|
||||
@@ -338,12 +321,7 @@ export async function getOrganizationBillingData(
|
||||
const pendingSeats = await countPendingSeatInvitations(organizationId, executor)
|
||||
const usedSeats = membersTotal + pendingSeats
|
||||
const memberUsageCounts = billingPeriod
|
||||
? await getOrganizationMemberUsageCounts(
|
||||
organizationId,
|
||||
billingPeriod,
|
||||
includeLegacyBaseline,
|
||||
executor
|
||||
)
|
||||
? await getOrganizationMemberUsageCounts(organizationId, billingPeriod, executor)
|
||||
: { overLimit: 0, nearLimit: 0 }
|
||||
|
||||
const billingPeriodStart = billingPeriod?.start ?? null
|
||||
|
||||
@@ -180,8 +180,8 @@ async function resolveBillingContext(
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns post-cutover usage for an attributed billing entity/period.
|
||||
* Legacy pre-cutover usage remains in userStats as a baseline until reset.
|
||||
* Returns attributed ledger usage for a billing entity/period. The ledger is
|
||||
* the sole source of truth for usage — there is no userStats baseline.
|
||||
*/
|
||||
export async function getBillingPeriodUsageCost(
|
||||
billingEntity: BillingEntity,
|
||||
@@ -333,6 +333,47 @@ export async function getBillingPeriodUsageCostByUser(
|
||||
return new Map(rows.map((row) => [row.userId, Number.parseFloat(row.cost ?? '0')]))
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-user ledger cost for every stamped billing period fully contained in
|
||||
* `[from, to]`. Rows are matched on their write-time period stamps
|
||||
* (`billing_period_start >= from AND billing_period_end <= to`), not on
|
||||
* `created_at`, so a row written moments after rollover but stamped with the
|
||||
* prior period is still attributed to that prior period.
|
||||
*
|
||||
* Used by the cycle-close sweep, whose window is normally exactly one period
|
||||
* (`from` = the closed period's start, `to` = its end == the current period's
|
||||
* start); a wider window absorbs multi-period catch-up after missed sweeps.
|
||||
*/
|
||||
export async function getStampedPeriodRangeUsageCostByUser(
|
||||
billingEntity: BillingEntity,
|
||||
range: { from: Date; to: Date },
|
||||
source?: UsageLogSource | UsageLogSource[],
|
||||
executor: DbClient = db
|
||||
): Promise<Map<string, number>> {
|
||||
const conditions = [
|
||||
eq(usageLog.billingEntityType, billingEntity.type),
|
||||
eq(usageLog.billingEntityId, billingEntity.id),
|
||||
gte(usageLog.billingPeriodStart, range.from),
|
||||
lte(usageLog.billingPeriodEnd, range.to),
|
||||
]
|
||||
if (source) {
|
||||
conditions.push(
|
||||
Array.isArray(source) ? inArray(usageLog.source, source) : eq(usageLog.source, source)
|
||||
)
|
||||
}
|
||||
|
||||
const rows = await executor
|
||||
.select({
|
||||
userId: usageLog.userId,
|
||||
cost: sql<string>`COALESCE(SUM(${usageLog.cost}), 0)`,
|
||||
})
|
||||
.from(usageLog)
|
||||
.where(and(...conditions))
|
||||
.groupBy(usageLog.userId)
|
||||
|
||||
return new Map(rows.map((row) => [row.userId, Number.parseFloat(row.cost ?? '0')]))
|
||||
}
|
||||
|
||||
/**
|
||||
* Records usage as append-only billing events.
|
||||
*
|
||||
|
||||
@@ -60,7 +60,6 @@ vi.mock('@/lib/billing/core/usage-log', () => ({
|
||||
|
||||
vi.mock('@/lib/billing/credits/daily-refresh', () => ({
|
||||
computeDailyRefreshConsumed: vi.fn(),
|
||||
getOrgMemberRefreshBounds: vi.fn(),
|
||||
}))
|
||||
|
||||
const {
|
||||
|
||||
@@ -22,12 +22,9 @@ import {
|
||||
type ResolvedUsagePeriod,
|
||||
resolveSubscriptionUsagePeriod,
|
||||
} from '@/lib/billing/core/reporting-period'
|
||||
import { getBillingPeriodUsageCost } from '@/lib/billing/core/usage-log'
|
||||
import {
|
||||
computeDailyRefreshConsumed,
|
||||
getOrgMemberRefreshBounds,
|
||||
} from '@/lib/billing/credits/daily-refresh'
|
||||
import { getPlanTierDollars, isEnterprise, isFree, isPaid, isPro } from '@/lib/billing/plan-helpers'
|
||||
import { type BillingEntity, getBillingPeriodUsageCost } from '@/lib/billing/core/usage-log'
|
||||
import { computeDailyRefreshConsumed } from '@/lib/billing/credits/daily-refresh'
|
||||
import { getPlanTierDollars, isEnterprise, isFree, isPaid } from '@/lib/billing/plan-helpers'
|
||||
import {
|
||||
canEditUsageLimit,
|
||||
getFreeTierLimit,
|
||||
@@ -66,45 +63,29 @@ export interface UsageLimitSubscription {
|
||||
}
|
||||
|
||||
/**
|
||||
* Sum `currentPeriodCost` across all members of an organization.
|
||||
* The single source of truth for pooled-usage reads so every caller
|
||||
* applies identical null-handling and query shape. Does NOT apply
|
||||
* daily-refresh deduction — callers layer that on top themselves
|
||||
* because refresh math needs the caller's `sub` context (plan,
|
||||
* period, seats, per-user bounds).
|
||||
* Pooled previous-period bookkeeping total for an organization — the sum of
|
||||
* member `lastPeriodCost` rows, which the cycle-close sweep writes from
|
||||
* ledger sums. Current-period usage is never read here — it is always the
|
||||
* attributed usage_log ledger.
|
||||
*
|
||||
* Uses `LEFT JOIN` so members whose `userStats` row is missing still
|
||||
* appear (contributing 0), which keeps `memberIds` complete for
|
||||
* downstream refresh / bounds computations.
|
||||
* count (contributing 0).
|
||||
*/
|
||||
export async function getPooledOrgCurrentPeriodCost(
|
||||
export async function getOrgLastPeriodCost(
|
||||
organizationId: string,
|
||||
executor: DbClient = db
|
||||
): Promise<{ memberIds: string[]; currentPeriodCost: number; lastPeriodCost: number }> {
|
||||
): Promise<number> {
|
||||
const rows = await executor
|
||||
.select({
|
||||
userId: member.userId,
|
||||
currentPeriodCost: userStats.currentPeriodCost,
|
||||
lastPeriodCost: userStats.lastPeriodCost,
|
||||
})
|
||||
.select({ lastPeriodCost: userStats.lastPeriodCost })
|
||||
.from(member)
|
||||
.leftJoin(userStats, eq(member.userId, userStats.userId))
|
||||
.where(eq(member.organizationId, organizationId))
|
||||
|
||||
let pooled = new Decimal(0)
|
||||
let lastPeriodCost = new Decimal(0)
|
||||
const memberIds: string[] = []
|
||||
for (const row of rows) {
|
||||
memberIds.push(row.userId)
|
||||
pooled = pooled.plus(toDecimal(row.currentPeriodCost))
|
||||
lastPeriodCost = lastPeriodCost.plus(toDecimal(row.lastPeriodCost))
|
||||
}
|
||||
|
||||
return {
|
||||
memberIds,
|
||||
currentPeriodCost: toNumber(pooled),
|
||||
lastPeriodCost: toNumber(lastPeriodCost),
|
||||
}
|
||||
return toNumber(lastPeriodCost)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -242,41 +223,17 @@ export async function getResolvedUserUsageData(
|
||||
interval: null,
|
||||
}
|
||||
|
||||
let currentUsageDecimal = toDecimal(
|
||||
billingPeriod.source === 'reporting' ? 0 : stats.currentPeriodCost
|
||||
)
|
||||
if (!orgScoped) {
|
||||
currentUsageDecimal = currentUsageDecimal.plus(
|
||||
await getBillingPeriodUsageCost(
|
||||
let currentUsage = orgScoped
|
||||
? 0
|
||||
: await getBillingPeriodUsageCost(
|
||||
{ type: 'user', id: userId },
|
||||
billingPeriod,
|
||||
undefined,
|
||||
executor
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// For personally-scoped Pro users, include any snapshotted usage from
|
||||
// a prior org-join so the display reflects their total Pro usage.
|
||||
if (subscription && isPro(subscription.plan) && !orgScoped) {
|
||||
const snapshotUsageDecimal = toDecimal(stats.proPeriodCostSnapshot)
|
||||
if (snapshotUsageDecimal.greaterThan(0)) {
|
||||
currentUsageDecimal = currentUsageDecimal.plus(snapshotUsageDecimal)
|
||||
logger.info('Including Pro snapshot in usage display', {
|
||||
userId,
|
||||
currentPeriodCost: stats.currentPeriodCost,
|
||||
proPeriodCostSnapshot: toNumber(snapshotUsageDecimal),
|
||||
totalUsage: toNumber(currentUsageDecimal),
|
||||
})
|
||||
}
|
||||
}
|
||||
let currentUsage = toNumber(currentUsageDecimal)
|
||||
let lastPeriodCost = toNumber(toDecimal(stats.lastPeriodCost))
|
||||
|
||||
let limit: number
|
||||
// Shared between the pooled-usage and pooled-refresh blocks so we
|
||||
// don't issue the member lookup twice per org-scoped call.
|
||||
let orgMemberIds: string[] = []
|
||||
|
||||
if (orgScoped && subscription) {
|
||||
const orgLimit = await getOrgUsageLimit(
|
||||
@@ -287,17 +244,13 @@ export async function getResolvedUserUsageData(
|
||||
)
|
||||
limit = orgLimit.limit
|
||||
|
||||
const pooled = await getPooledOrgCurrentPeriodCost(subscription.referenceId, executor)
|
||||
orgMemberIds = pooled.memberIds
|
||||
lastPeriodCost = pooled.lastPeriodCost
|
||||
const ledgerUsage = await getBillingPeriodUsageCost(
|
||||
lastPeriodCost = await getOrgLastPeriodCost(subscription.referenceId, executor)
|
||||
currentUsage = await getBillingPeriodUsageCost(
|
||||
{ type: 'organization', id: subscription.referenceId },
|
||||
billingPeriod,
|
||||
undefined,
|
||||
executor
|
||||
)
|
||||
currentUsage =
|
||||
(billingPeriod.source === 'reporting' ? 0 : pooled.currentPeriodCost) + ledgerUsage
|
||||
} else {
|
||||
limit = stats.currentUsageLimit
|
||||
? toNumber(toDecimal(stats.currentUsageLimit))
|
||||
@@ -311,38 +264,18 @@ export async function getResolvedUserUsageData(
|
||||
if (subscription && isPaid(subscription.plan) && billingPeriodStart) {
|
||||
const planDollars = getPlanTierDollars(subscription.plan)
|
||||
if (planDollars > 0) {
|
||||
if (orgScoped) {
|
||||
if (orgMemberIds.length > 0) {
|
||||
const userBounds = await getOrgMemberRefreshBounds(
|
||||
subscription.referenceId,
|
||||
billingPeriodStart,
|
||||
executor
|
||||
)
|
||||
dailyRefreshConsumed = await computeDailyRefreshConsumed(
|
||||
{
|
||||
userIds: orgMemberIds,
|
||||
periodStart: billingPeriodStart,
|
||||
periodEnd: billingPeriodEnd,
|
||||
planDollars,
|
||||
seats: subscription.seats || 1,
|
||||
userBounds: Object.keys(userBounds).length > 0 ? userBounds : undefined,
|
||||
billingEntity: { type: 'organization', id: subscription.referenceId },
|
||||
},
|
||||
executor
|
||||
)
|
||||
}
|
||||
} else {
|
||||
dailyRefreshConsumed = await computeDailyRefreshConsumed(
|
||||
{
|
||||
userIds: [userId],
|
||||
periodStart: billingPeriodStart,
|
||||
periodEnd: billingPeriodEnd,
|
||||
planDollars,
|
||||
billingEntity: { type: 'user', id: userId },
|
||||
},
|
||||
executor
|
||||
)
|
||||
}
|
||||
dailyRefreshConsumed = await computeDailyRefreshConsumed(
|
||||
{
|
||||
billingEntity: orgScoped
|
||||
? { type: 'organization', id: subscription.referenceId }
|
||||
: { type: 'user', id: userId },
|
||||
periodStart: billingPeriodStart,
|
||||
periodEnd: billingPeriodEnd,
|
||||
planDollars,
|
||||
seats: orgScoped ? subscription.seats || 1 : undefined,
|
||||
},
|
||||
executor
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -706,51 +639,19 @@ export async function getEffectiveCurrentPeriodCost(
|
||||
const subscription = await getHighestPrioritySubscription(userId, { executor })
|
||||
const orgScoped = isOrgScopedSubscription(subscription, userId)
|
||||
|
||||
let rawCost: number
|
||||
let refreshUserIds: string[] = [userId]
|
||||
|
||||
if (orgScoped && subscription) {
|
||||
const pooled = await getPooledOrgCurrentPeriodCost(subscription.referenceId, executor)
|
||||
if (pooled.memberIds.length === 0) return 0
|
||||
refreshUserIds = pooled.memberIds
|
||||
const billingPeriod = resolveSubscriptionUsagePeriod(subscription) ?? {
|
||||
...defaultBillingPeriod(),
|
||||
source: 'default' as const,
|
||||
anchorDate: null,
|
||||
interval: null,
|
||||
}
|
||||
rawCost =
|
||||
(billingPeriod.source === 'reporting' ? 0 : pooled.currentPeriodCost) +
|
||||
(await getBillingPeriodUsageCost(
|
||||
{ type: 'organization', id: subscription.referenceId },
|
||||
billingPeriod,
|
||||
undefined,
|
||||
executor
|
||||
))
|
||||
} else {
|
||||
const rows = await executor
|
||||
.select({ current: userStats.currentPeriodCost })
|
||||
.from(userStats)
|
||||
.where(eq(userStats.userId, userId))
|
||||
.limit(1)
|
||||
|
||||
if (rows.length === 0) return 0
|
||||
const billingPeriod = resolveSubscriptionUsagePeriod(subscription) ?? {
|
||||
...defaultBillingPeriod(),
|
||||
source: 'default' as const,
|
||||
anchorDate: null,
|
||||
interval: null,
|
||||
}
|
||||
rawCost =
|
||||
(billingPeriod.source === 'reporting' ? 0 : toNumber(toDecimal(rows[0].current))) +
|
||||
(await getBillingPeriodUsageCost(
|
||||
{ type: 'user', id: userId },
|
||||
billingPeriod,
|
||||
undefined,
|
||||
executor
|
||||
))
|
||||
const billingPeriod = resolveSubscriptionUsagePeriod(subscription) ?? {
|
||||
...defaultBillingPeriod(),
|
||||
source: 'default' as const,
|
||||
anchorDate: null,
|
||||
interval: null,
|
||||
}
|
||||
|
||||
const billingEntity: BillingEntity =
|
||||
orgScoped && subscription
|
||||
? { type: 'organization', id: subscription.referenceId }
|
||||
: { type: 'user', id: userId }
|
||||
const rawCost = await getBillingPeriodUsageCost(billingEntity, billingPeriod, undefined, executor)
|
||||
|
||||
if (!subscription || !isPaid(subscription.plan) || !subscription.periodStart) {
|
||||
return rawCost
|
||||
}
|
||||
@@ -758,27 +659,13 @@ export async function getEffectiveCurrentPeriodCost(
|
||||
const planDollars = getPlanTierDollars(subscription.plan)
|
||||
if (planDollars <= 0) return rawCost
|
||||
|
||||
const userBounds =
|
||||
orgScoped && subscription.periodStart
|
||||
? await getOrgMemberRefreshBounds(
|
||||
subscription.referenceId,
|
||||
subscription.periodStart,
|
||||
executor
|
||||
)
|
||||
: {}
|
||||
|
||||
const refreshConsumed = await computeDailyRefreshConsumed(
|
||||
{
|
||||
userIds: refreshUserIds,
|
||||
billingEntity,
|
||||
periodStart: subscription.periodStart,
|
||||
periodEnd: subscription.periodEnd ?? null,
|
||||
planDollars,
|
||||
seats: subscription.seats || 1,
|
||||
userBounds: Object.keys(userBounds).length > 0 ? userBounds : undefined,
|
||||
billingEntity:
|
||||
orgScoped && subscription
|
||||
? { type: 'organization', id: subscription.referenceId }
|
||||
: { type: 'user', id: userId },
|
||||
},
|
||||
executor
|
||||
)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { dbChainMockFns, drizzleOrmMock, schemaMock } from '@sim/testing'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('drizzle-orm', () => {
|
||||
const sqlTag = () => {
|
||||
@@ -23,9 +23,23 @@ vi.mock('@/lib/billing/constants', () => ({
|
||||
import {
|
||||
computeBillingPeriodUsageWithDailyRefresh,
|
||||
computeDailyRefreshConsumed,
|
||||
getDailyRefreshDollars,
|
||||
} from '@/lib/billing/credits/daily-refresh'
|
||||
|
||||
/**
|
||||
* Refresh caps windows at `Date.now()`, so the suite pins the clock after
|
||||
* every fixture period to stay hermetic on any host date.
|
||||
*/
|
||||
const FROZEN_NOW = new Date('2026-08-15T00:00:00.000Z')
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(FROZEN_NOW)
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('computeBillingPeriodUsageWithDailyRefresh', () => {
|
||||
const periodStart = new Date('2026-03-01T00:00:00.000Z')
|
||||
const periodEnd = new Date('2026-04-01T00:00:00.000Z')
|
||||
@@ -44,7 +58,6 @@ describe('computeBillingPeriodUsageWithDailyRefresh', () => {
|
||||
computeBillingPeriodUsageWithDailyRefresh({
|
||||
billingEntity: { type: 'organization', id: 'org-1' },
|
||||
billingPeriod: { start: periodStart, end: periodEnd },
|
||||
userIds: ['user-1'],
|
||||
refreshPeriodStart: periodStart,
|
||||
refreshPeriodEnd: periodEnd,
|
||||
planDollars: 25,
|
||||
@@ -72,7 +85,6 @@ describe('computeBillingPeriodUsageWithDailyRefresh', () => {
|
||||
end: reportingEnd,
|
||||
source: 'reporting',
|
||||
},
|
||||
userIds: ['user-1'],
|
||||
refreshPeriodStart: periodStart,
|
||||
refreshPeriodEnd: periodEnd,
|
||||
planDollars: 25,
|
||||
@@ -89,28 +101,6 @@ describe('computeBillingPeriodUsageWithDailyRefresh', () => {
|
||||
reportingEnd
|
||||
)
|
||||
})
|
||||
|
||||
it('preserves a bounded user refresh window without narrowing the ledger total', async () => {
|
||||
const userStart = new Date('2026-03-10T00:00:00.000Z')
|
||||
const userEnd = new Date('2026-03-20T00:00:00.000Z')
|
||||
dbChainMockFns.groupBy.mockResolvedValueOnce([
|
||||
{ ledgerTotal: '30.00', refreshDayTotal: '0.25' },
|
||||
])
|
||||
|
||||
await computeBillingPeriodUsageWithDailyRefresh({
|
||||
billingEntity: { type: 'organization', id: 'org-1' },
|
||||
billingPeriod: { start: periodStart, end: periodEnd },
|
||||
userIds: ['bounded-user'],
|
||||
refreshPeriodStart: periodStart,
|
||||
refreshPeriodEnd: periodEnd,
|
||||
planDollars: 25,
|
||||
userBounds: { 'bounded-user': { userStart, userEnd } },
|
||||
})
|
||||
|
||||
expect(drizzleOrmMock.eq).toHaveBeenCalledWith(schemaMock.usageLog.userId, 'bounded-user')
|
||||
expect(drizzleOrmMock.gte).toHaveBeenCalledWith(schemaMock.usageLog.createdAt, userStart)
|
||||
expect(drizzleOrmMock.lt).toHaveBeenCalledWith(schemaMock.usageLog.createdAt, userEnd)
|
||||
})
|
||||
})
|
||||
|
||||
describe('computeDailyRefreshConsumed', () => {
|
||||
@@ -120,7 +110,7 @@ describe('computeDailyRefreshConsumed', () => {
|
||||
|
||||
it('returns 0 when planDollars is 0', async () => {
|
||||
const result = await computeDailyRefreshConsumed({
|
||||
userIds: ['user-1'],
|
||||
billingEntity: { type: 'user', id: 'user-1' },
|
||||
periodStart: new Date('2026-03-01'),
|
||||
planDollars: 0,
|
||||
})
|
||||
@@ -128,19 +118,9 @@ describe('computeDailyRefreshConsumed', () => {
|
||||
expect(dbChainMockFns.groupBy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns 0 when userIds is empty', async () => {
|
||||
const result = await computeDailyRefreshConsumed({
|
||||
userIds: [],
|
||||
periodStart: new Date('2026-03-01'),
|
||||
planDollars: 25,
|
||||
})
|
||||
expect(result).toBe(0)
|
||||
expect(dbChainMockFns.groupBy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns 0 when periodEnd is before periodStart', async () => {
|
||||
const result = await computeDailyRefreshConsumed({
|
||||
userIds: ['user-1'],
|
||||
billingEntity: { type: 'user', id: 'user-1' },
|
||||
periodStart: new Date('2026-03-10'),
|
||||
periodEnd: new Date('2026-03-01'),
|
||||
planDollars: 25,
|
||||
@@ -148,6 +128,63 @@ describe('computeDailyRefreshConsumed', () => {
|
||||
expect(result).toBe(0)
|
||||
})
|
||||
|
||||
it('scopes rows by the entity and period stamps, never an actor list', async () => {
|
||||
dbChainMockFns.groupBy.mockResolvedValueOnce([{ dayIndex: 0, dayTotal: '0.10' }])
|
||||
const periodStart = new Date('2026-03-01')
|
||||
|
||||
await computeDailyRefreshConsumed({
|
||||
billingEntity: { type: 'organization', id: 'org-1' },
|
||||
periodStart,
|
||||
periodEnd: new Date('2026-03-02'),
|
||||
planDollars: 25,
|
||||
})
|
||||
|
||||
expect(drizzleOrmMock.eq).toHaveBeenCalledWith(
|
||||
schemaMock.usageLog.billingEntityType,
|
||||
'organization'
|
||||
)
|
||||
expect(drizzleOrmMock.eq).toHaveBeenCalledWith(schemaMock.usageLog.billingEntityId, 'org-1')
|
||||
expect(drizzleOrmMock.eq).toHaveBeenCalledWith(
|
||||
schemaMock.usageLog.billingPeriodStart,
|
||||
periodStart
|
||||
)
|
||||
expect(drizzleOrmMock.inArray).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps straggler rows stamped to the period but written after its end', async () => {
|
||||
// A run that started before the rollover inserts rows stamped with the
|
||||
// elapsed period after it ended; the stamp-based close bills them, so the
|
||||
// deduction must include them too (clamped into the final day bucket).
|
||||
dbChainMockFns.groupBy.mockResolvedValueOnce([{ dayIndex: 30, dayTotal: '0.30' }])
|
||||
const periodStart = new Date('2026-03-01')
|
||||
const periodEnd = new Date('2026-04-01')
|
||||
|
||||
const result = await computeDailyRefreshConsumed({
|
||||
billingEntity: { type: 'user', id: 'user-1' },
|
||||
periodStart,
|
||||
periodEnd,
|
||||
planDollars: 25,
|
||||
})
|
||||
|
||||
expect(result).toBe(0.25)
|
||||
// Membership is stamp-only: no created-at bound may exclude a row the
|
||||
// stamped ledger total includes.
|
||||
expect(drizzleOrmMock.lt).not.toHaveBeenCalledWith(schemaMock.usageLog.createdAt, periodEnd)
|
||||
expect(drizzleOrmMock.gte).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects windows beyond the supported annual bound', async () => {
|
||||
await expect(
|
||||
computeDailyRefreshConsumed({
|
||||
billingEntity: { type: 'organization', id: 'org-1' },
|
||||
periodStart: new Date('2024-01-01'),
|
||||
periodEnd: new Date('2026-03-01'),
|
||||
planDollars: 25,
|
||||
})
|
||||
).rejects.toThrow('annual bound')
|
||||
expect(dbChainMockFns.groupBy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('caps each day at the daily refresh allowance', async () => {
|
||||
dbChainMockFns.groupBy.mockResolvedValueOnce([
|
||||
{ dayIndex: 0, dayTotal: '0.50' },
|
||||
@@ -156,7 +193,7 @@ describe('computeDailyRefreshConsumed', () => {
|
||||
])
|
||||
|
||||
const result = await computeDailyRefreshConsumed({
|
||||
userIds: ['user-1'],
|
||||
billingEntity: { type: 'user', id: 'user-1' },
|
||||
periodStart: new Date('2026-03-01'),
|
||||
periodEnd: new Date('2026-03-04'),
|
||||
planDollars: 25,
|
||||
@@ -174,7 +211,7 @@ describe('computeDailyRefreshConsumed', () => {
|
||||
dbChainMockFns.groupBy.mockResolvedValueOnce([])
|
||||
|
||||
const result = await computeDailyRefreshConsumed({
|
||||
userIds: ['user-1'],
|
||||
billingEntity: { type: 'user', id: 'user-1' },
|
||||
periodStart: new Date('2026-03-01'),
|
||||
periodEnd: new Date('2026-03-04'),
|
||||
planDollars: 25,
|
||||
@@ -187,7 +224,7 @@ describe('computeDailyRefreshConsumed', () => {
|
||||
dbChainMockFns.groupBy.mockResolvedValueOnce([{ dayIndex: 0, dayTotal: '2.00' }])
|
||||
|
||||
const result = await computeDailyRefreshConsumed({
|
||||
userIds: ['user-1', 'user-2', 'user-3'],
|
||||
billingEntity: { type: 'organization', id: 'org-1' },
|
||||
periodStart: new Date('2026-03-01'),
|
||||
periodEnd: new Date('2026-03-02'),
|
||||
planDollars: 100,
|
||||
@@ -203,7 +240,7 @@ describe('computeDailyRefreshConsumed', () => {
|
||||
dbChainMockFns.groupBy.mockResolvedValueOnce([{ dayIndex: 0, dayTotal: '50.00' }])
|
||||
|
||||
const result = await computeDailyRefreshConsumed({
|
||||
userIds: ['user-1', 'user-2'],
|
||||
billingEntity: { type: 'organization', id: 'org-1' },
|
||||
periodStart: new Date('2026-03-01'),
|
||||
periodEnd: new Date('2026-03-02'),
|
||||
planDollars: 100,
|
||||
@@ -219,7 +256,7 @@ describe('computeDailyRefreshConsumed', () => {
|
||||
dbChainMockFns.groupBy.mockResolvedValueOnce([{ dayIndex: 0, dayTotal: null }])
|
||||
|
||||
const result = await computeDailyRefreshConsumed({
|
||||
userIds: ['user-1'],
|
||||
billingEntity: { type: 'user', id: 'user-1' },
|
||||
periodStart: new Date('2026-03-01'),
|
||||
periodEnd: new Date('2026-03-02'),
|
||||
planDollars: 25,
|
||||
@@ -228,17 +265,3 @@ describe('computeDailyRefreshConsumed', () => {
|
||||
expect(result).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getDailyRefreshDollars', () => {
|
||||
it('computes correct daily refresh for Pro ($25)', () => {
|
||||
expect(getDailyRefreshDollars(25)).toBe(0.25)
|
||||
})
|
||||
|
||||
it('computes correct daily refresh for Max ($100)', () => {
|
||||
expect(getDailyRefreshDollars(100)).toBe(1.0)
|
||||
})
|
||||
|
||||
it('returns 0 for $0 plan', () => {
|
||||
expect(getDailyRefreshDollars(0)).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,13 +8,20 @@
|
||||
* The total refresh consumed in a period is:
|
||||
* SUM( MIN(day_usage, daily_refresh_amount) ) for each day
|
||||
*
|
||||
* This is subtracted from `currentPeriodCost` to derive "effective billable usage".
|
||||
* This is subtracted from ledger period usage to derive "effective billable usage".
|
||||
*
|
||||
* Refresh reads are scoped by the ledger's write-time entity and period
|
||||
* stamps — never by an actor list. Every row attributed to the billing entity
|
||||
* participates in that entity's refresh, exactly like it participates in the
|
||||
* entity's ledger total: rows from a member who departed the organization
|
||||
* mid-period stay stamped to the organization, and a member's pre-join rows
|
||||
* are user-stamped, so they can never appear under an organization entity.
|
||||
*/
|
||||
|
||||
import { db } from '@sim/db'
|
||||
import { member, usageLog, userStats } from '@sim/db/schema'
|
||||
import { usageLog } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq, gte, inArray, isNull, lt, lte, or, sql, sum } from 'drizzle-orm'
|
||||
import { and, eq, gte, lt, or, sql, sum } from 'drizzle-orm'
|
||||
import { DAILY_REFRESH_RATE } from '@/lib/billing/constants'
|
||||
import type { BillingEntity, UsageQueryPeriod } from '@/lib/billing/core/usage-log'
|
||||
import type { DbClient } from '@/lib/db/types'
|
||||
@@ -24,34 +31,23 @@ const logger = createLogger('DailyRefresh')
|
||||
const MS_PER_DAY = 86_400_000
|
||||
const MAX_BILLING_PERIOD_DAYS = 370
|
||||
|
||||
/**
|
||||
* Optional per-user date window. `usageLog` rows outside
|
||||
* `[userStart, userEnd)` are excluded from that user's contribution.
|
||||
* Used to slice refresh around a mid-cycle org join so pre-join and
|
||||
* post-join refresh are billed by the right subscription.
|
||||
*/
|
||||
export interface PerUserBounds {
|
||||
userStart?: Date | null
|
||||
userEnd?: Date | null
|
||||
}
|
||||
|
||||
interface BillingPeriodUsageWithDailyRefreshParams {
|
||||
billingEntity: BillingEntity
|
||||
billingPeriod: UsageQueryPeriod
|
||||
userIds: string[]
|
||||
refreshPeriodStart: Date
|
||||
refreshPeriodEnd?: Date | null
|
||||
planDollars: number
|
||||
seats?: number
|
||||
userBounds?: Record<string, PerUserBounds>
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the exact ledger total and the daily-refresh buckets from one snapshot.
|
||||
*
|
||||
* The two aggregates intentionally keep different predicates. Ledger totals use
|
||||
* both captured period bounds (or a reporting-time window), while refresh uses
|
||||
* the captured period start, eligible users, and per-user time bounds.
|
||||
* The two aggregates intentionally keep different predicates. Ledger totals
|
||||
* use both captured period bounds (or a reporting-time window), while refresh
|
||||
* membership is the captured period-start stamp alone — created-at only
|
||||
* buckets rows into days, clamped into the period (see
|
||||
* `computeDailyRefreshConsumed` for why).
|
||||
*/
|
||||
export async function computeBillingPeriodUsageWithDailyRefresh(
|
||||
params: BillingPeriodUsageWithDailyRefreshParams,
|
||||
@@ -60,58 +56,18 @@ export async function computeBillingPeriodUsageWithDailyRefresh(
|
||||
const {
|
||||
billingEntity,
|
||||
billingPeriod,
|
||||
userIds,
|
||||
refreshPeriodStart,
|
||||
refreshPeriodEnd,
|
||||
planDollars,
|
||||
seats = 1,
|
||||
userBounds,
|
||||
} = params
|
||||
const now = new Date()
|
||||
const cap = refreshPeriodEnd && refreshPeriodEnd < now ? refreshPeriodEnd : now
|
||||
const dailyRefreshDollars = planDollars * DAILY_REFRESH_RATE * seats
|
||||
const eligibleUserIds = new Set(userIds)
|
||||
const unboundedUsers = userBounds ? userIds.filter((id) => !(id in userBounds)) : userIds
|
||||
const boundedClauses = userBounds
|
||||
? Object.entries(userBounds).flatMap(([userId, bounds]) => {
|
||||
if (!eligibleUserIds.has(userId)) return []
|
||||
const effectiveStart =
|
||||
bounds.userStart && bounds.userStart > refreshPeriodStart
|
||||
? bounds.userStart
|
||||
: refreshPeriodStart
|
||||
const effectiveEnd = bounds.userEnd && bounds.userEnd < cap ? bounds.userEnd : cap
|
||||
if (effectiveEnd <= effectiveStart) return []
|
||||
return [
|
||||
and(
|
||||
eq(usageLog.userId, userId),
|
||||
gte(usageLog.createdAt, effectiveStart),
|
||||
lt(usageLog.createdAt, effectiveEnd)
|
||||
),
|
||||
]
|
||||
})
|
||||
: []
|
||||
const refreshUserFilters =
|
||||
cap > refreshPeriodStart
|
||||
? [
|
||||
...(unboundedUsers.length > 0
|
||||
? [
|
||||
and(
|
||||
inArray(usageLog.userId, unboundedUsers),
|
||||
gte(usageLog.createdAt, refreshPeriodStart),
|
||||
lt(usageLog.createdAt, cap)
|
||||
),
|
||||
]
|
||||
: []),
|
||||
...boundedClauses,
|
||||
]
|
||||
: []
|
||||
const refreshFilter =
|
||||
refreshUserFilters.length > 0
|
||||
? and(
|
||||
eq(usageLog.billingPeriodStart, refreshPeriodStart),
|
||||
refreshUserFilters.length === 1 ? refreshUserFilters[0] : or(...refreshUserFilters)
|
||||
)
|
||||
: sql<boolean>`false`
|
||||
const refreshWindowActive = cap > refreshPeriodStart
|
||||
const refreshFilter = refreshWindowActive
|
||||
? eq(usageLog.billingPeriodStart, refreshPeriodStart)
|
||||
: sql<boolean>`false`
|
||||
const ledgerPeriodFilter =
|
||||
billingPeriod.source === 'reporting'
|
||||
? and(gte(usageLog.createdAt, billingPeriod.start), lt(usageLog.createdAt, billingPeriod.end))
|
||||
@@ -123,23 +79,18 @@ export async function computeBillingPeriodUsageWithDailyRefresh(
|
||||
const sameCapturedPeriodStart =
|
||||
billingPeriod.source !== 'reporting' &&
|
||||
billingPeriod.start.getTime() === refreshPeriodStart.getTime()
|
||||
const reportingWindowContainsRefresh =
|
||||
billingPeriod.source === 'reporting' &&
|
||||
refreshPeriodStart >= billingPeriod.start &&
|
||||
cap <= billingPeriod.end
|
||||
const scanFilter =
|
||||
refreshUserFilters.length === 0
|
||||
? ledgerPeriodFilter
|
||||
: sameCapturedPeriodStart
|
||||
? eq(usageLog.billingPeriodStart, billingPeriod.start)
|
||||
: reportingWindowContainsRefresh
|
||||
? ledgerPeriodFilter
|
||||
: or(ledgerPeriodFilter, refreshFilter)
|
||||
const scanFilter = !refreshWindowActive
|
||||
? ledgerPeriodFilter
|
||||
: sameCapturedPeriodStart
|
||||
? eq(usageLog.billingPeriodStart, billingPeriod.start)
|
||||
: or(ledgerPeriodFilter, refreshFilter)
|
||||
|
||||
const startEpoch = Math.floor(refreshPeriodStart.getTime() / 1000)
|
||||
const capEpoch = Math.floor(cap.getTime() / 1000)
|
||||
const rows = await executor
|
||||
.select({
|
||||
dayIndex:
|
||||
sql<number>`FLOOR((EXTRACT(EPOCH FROM ${usageLog.createdAt}) - ${Math.floor(refreshPeriodStart.getTime() / 1000)}) / 86400)`.as(
|
||||
sql<number>`FLOOR((LEAST(GREATEST(EXTRACT(EPOCH FROM ${usageLog.createdAt}), ${startEpoch}), ${capEpoch - 1}) - ${startEpoch}) / 86400)`.as(
|
||||
'day_index'
|
||||
),
|
||||
ledgerTotal:
|
||||
@@ -173,37 +124,30 @@ export async function computeBillingPeriodUsageWithDailyRefresh(
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the total daily refresh credits consumed in the current billing period
|
||||
* using a single aggregating SQL query grouped by day offset.
|
||||
* Compute the total daily refresh credits a billing entity consumed in a
|
||||
* period, using a single aggregating SQL query grouped by day offset.
|
||||
*
|
||||
* For each day from `periodStart`:
|
||||
* consumed_today = MIN(actual_usage_today, daily_refresh_dollars)
|
||||
*
|
||||
* Rows are scoped purely by the entity and period stamps — see the module
|
||||
* header for why no actor list participates.
|
||||
*
|
||||
* @returns Total dollars of refresh consumed across all days (to subtract from usage)
|
||||
*/
|
||||
export async function computeDailyRefreshConsumed(
|
||||
params: {
|
||||
userIds: string[]
|
||||
billingEntity: BillingEntity
|
||||
periodStart: Date
|
||||
periodEnd?: Date | null
|
||||
planDollars: number
|
||||
seats?: number
|
||||
userBounds?: Record<string, PerUserBounds>
|
||||
billingEntity?: { type: 'user' | 'organization'; id: string }
|
||||
},
|
||||
executor: DbClient = db
|
||||
): Promise<number> {
|
||||
const {
|
||||
userIds,
|
||||
periodStart,
|
||||
periodEnd,
|
||||
planDollars,
|
||||
seats = 1,
|
||||
userBounds,
|
||||
billingEntity,
|
||||
} = params
|
||||
const { billingEntity, periodStart, periodEnd, planDollars, seats = 1 } = params
|
||||
|
||||
if (planDollars <= 0 || userIds.length === 0) return 0
|
||||
if (planDollars <= 0) return 0
|
||||
|
||||
const dailyRefreshDollars = planDollars * DAILY_REFRESH_RATE * seats
|
||||
|
||||
@@ -213,60 +157,33 @@ export async function computeDailyRefreshConsumed(
|
||||
if (cap <= periodStart) return 0
|
||||
|
||||
const dayCount = Math.ceil((cap.getTime() - periodStart.getTime()) / MS_PER_DAY)
|
||||
if (dayCount <= 0) return 0
|
||||
|
||||
const unboundedUsers = userBounds ? userIds.filter((id) => !(id in userBounds)) : userIds
|
||||
const billingEntityFilter = billingEntity
|
||||
? and(
|
||||
eq(usageLog.billingEntityType, billingEntity.type),
|
||||
eq(usageLog.billingEntityId, billingEntity.id),
|
||||
eq(usageLog.billingPeriodStart, periodStart)
|
||||
)
|
||||
: undefined
|
||||
|
||||
const boundedClauses = userBounds
|
||||
? Object.entries(userBounds).flatMap(([userId, bounds]) => {
|
||||
if (!userIds.includes(userId)) return []
|
||||
const effectiveStart =
|
||||
bounds.userStart && bounds.userStart > periodStart ? bounds.userStart : periodStart
|
||||
const effectiveEnd = bounds.userEnd && bounds.userEnd < cap ? bounds.userEnd : cap
|
||||
if (effectiveEnd <= effectiveStart) return []
|
||||
return [
|
||||
and(
|
||||
eq(usageLog.userId, userId),
|
||||
billingEntityFilter,
|
||||
gte(usageLog.createdAt, effectiveStart),
|
||||
lt(usageLog.createdAt, effectiveEnd)
|
||||
),
|
||||
]
|
||||
})
|
||||
: []
|
||||
|
||||
const rowFilters =
|
||||
unboundedUsers.length > 0
|
||||
? [
|
||||
and(
|
||||
inArray(usageLog.userId, unboundedUsers),
|
||||
billingEntityFilter,
|
||||
gte(usageLog.createdAt, periodStart),
|
||||
lt(usageLog.createdAt, cap)
|
||||
),
|
||||
...boundedClauses,
|
||||
]
|
||||
: boundedClauses
|
||||
|
||||
if (rowFilters.length === 0) return 0
|
||||
if (dayCount > MAX_BILLING_PERIOD_DAYS) {
|
||||
throw new Error('Billing period exceeds the supported annual bound')
|
||||
}
|
||||
|
||||
// Membership mirrors the ledger sums exactly: the entity and period stamps
|
||||
// alone. Created-at only assigns the day bucket, clamped into the period —
|
||||
// a straggler row written after the rollover (billing attribution is frozen
|
||||
// at run start) is billed by the stamp-based close, so it must consume
|
||||
// refresh on the period's final day rather than fall out of the deduction.
|
||||
const startEpoch = Math.floor(periodStart.getTime() / 1000)
|
||||
const capEpoch = Math.floor(cap.getTime() / 1000)
|
||||
const rows = await executor
|
||||
.select({
|
||||
dayIndex:
|
||||
sql<number>`FLOOR((EXTRACT(EPOCH FROM ${usageLog.createdAt}) - ${Math.floor(periodStart.getTime() / 1000)}) / 86400)`.as(
|
||||
sql<number>`FLOOR((LEAST(GREATEST(EXTRACT(EPOCH FROM ${usageLog.createdAt}), ${startEpoch}), ${capEpoch - 1}) - ${startEpoch}) / 86400)`.as(
|
||||
'day_index'
|
||||
),
|
||||
dayTotal: sum(usageLog.cost).as('day_total'),
|
||||
})
|
||||
.from(usageLog)
|
||||
.where(rowFilters.length === 1 ? rowFilters[0] : or(...rowFilters))
|
||||
.where(
|
||||
and(
|
||||
eq(usageLog.billingEntityType, billingEntity.type),
|
||||
eq(usageLog.billingEntityId, billingEntity.id),
|
||||
eq(usageLog.billingPeriodStart, periodStart)
|
||||
)
|
||||
)
|
||||
.groupBy(sql`day_index`)
|
||||
|
||||
let totalConsumed = 0
|
||||
@@ -276,101 +193,12 @@ export async function computeDailyRefreshConsumed(
|
||||
}
|
||||
|
||||
logger.debug('Daily refresh computed', {
|
||||
userCount: userIds.length,
|
||||
billingEntityType: billingEntity.type,
|
||||
periodStart: periodStart.toISOString(),
|
||||
days: dayCount,
|
||||
dailyRefreshDollars,
|
||||
totalConsumed,
|
||||
hasUserBounds: Boolean(userBounds),
|
||||
})
|
||||
|
||||
return totalConsumed
|
||||
}
|
||||
|
||||
export async function computeOrganizationDailyRefreshConsumed(
|
||||
params: {
|
||||
organizationId: string
|
||||
periodStart: Date
|
||||
periodEnd?: Date | null
|
||||
planDollars: number
|
||||
seats?: number
|
||||
},
|
||||
executor: DbClient = db
|
||||
): Promise<number> {
|
||||
const { organizationId, periodStart, periodEnd, planDollars, seats = 1 } = params
|
||||
if (planDollars <= 0) return 0
|
||||
|
||||
const now = new Date()
|
||||
const cap = periodEnd && periodEnd < now ? periodEnd : now
|
||||
if (cap <= periodStart) return 0
|
||||
const dayCount = Math.ceil((cap.getTime() - periodStart.getTime()) / MS_PER_DAY)
|
||||
if (dayCount > MAX_BILLING_PERIOD_DAYS) {
|
||||
throw new Error('Organization billing period exceeds the supported annual bound')
|
||||
}
|
||||
|
||||
const dailyRefreshDollars = planDollars * DAILY_REFRESH_RATE * seats
|
||||
const rows = await executor
|
||||
.select({
|
||||
dayIndex:
|
||||
sql<number>`FLOOR((EXTRACT(EPOCH FROM ${usageLog.createdAt}) - ${Math.floor(periodStart.getTime() / 1000)}) / 86400)`.as(
|
||||
'day_index'
|
||||
),
|
||||
dayTotal: sum(usageLog.cost).as('day_total'),
|
||||
})
|
||||
.from(usageLog)
|
||||
.innerJoin(
|
||||
member,
|
||||
and(eq(member.userId, usageLog.userId), eq(member.organizationId, organizationId))
|
||||
)
|
||||
.leftJoin(userStats, eq(userStats.userId, member.userId))
|
||||
.where(
|
||||
and(
|
||||
eq(usageLog.billingEntityType, 'organization'),
|
||||
eq(usageLog.billingEntityId, organizationId),
|
||||
eq(usageLog.billingPeriodStart, periodStart),
|
||||
gte(usageLog.createdAt, periodStart),
|
||||
lt(usageLog.createdAt, cap),
|
||||
or(
|
||||
isNull(userStats.proPeriodCostSnapshotAt),
|
||||
lte(userStats.proPeriodCostSnapshotAt, periodStart),
|
||||
gte(usageLog.createdAt, userStats.proPeriodCostSnapshotAt)
|
||||
)
|
||||
)
|
||||
)
|
||||
.groupBy(sql`day_index`)
|
||||
|
||||
return rows.reduce((total, row) => {
|
||||
const dayUsage = Number.parseFloat(row.dayTotal ?? '0')
|
||||
return total + Math.min(dayUsage, dailyRefreshDollars)
|
||||
}, 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the daily refresh allowance in dollars for a plan.
|
||||
*/
|
||||
export function getDailyRefreshDollars(planDollars: number): number {
|
||||
return planDollars * DAILY_REFRESH_RATE
|
||||
}
|
||||
|
||||
export async function getOrgMemberRefreshBounds(
|
||||
organizationId: string,
|
||||
periodStart: Date,
|
||||
executor: DbClient = db
|
||||
): Promise<Record<string, { userStart: Date }>> {
|
||||
const rows = await executor
|
||||
.select({
|
||||
userId: member.userId,
|
||||
snapshotAt: userStats.proPeriodCostSnapshotAt,
|
||||
})
|
||||
.from(member)
|
||||
.leftJoin(userStats, eq(member.userId, userStats.userId))
|
||||
.where(eq(member.organizationId, organizationId))
|
||||
|
||||
const bounds: Record<string, { userStart: Date }> = {}
|
||||
for (const row of rows) {
|
||||
if (row.snapshotAt && row.snapshotAt > periodStart) {
|
||||
bounds[row.userId] = { userStart: row.snapshotAt }
|
||||
}
|
||||
}
|
||||
return bounds
|
||||
}
|
||||
|
||||
@@ -0,0 +1,665 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { dbChainMockFns, queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing'
|
||||
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const {
|
||||
mockComputeOrgOverageAmount,
|
||||
mockIsSubscriptionOrgScoped,
|
||||
mockGetStampedPeriodRangeUsageCostByUser,
|
||||
mockComputeDailyRefreshConsumed,
|
||||
mockEnqueueOutboxEvent,
|
||||
mockGetPlanPricing,
|
||||
mockGetPlanTierDollars,
|
||||
mockResolveSubscriptionUsagePeriod,
|
||||
mockIsEnterprise,
|
||||
mockIsFree,
|
||||
mockRecordAudit,
|
||||
mockCaptureServerEvent,
|
||||
} = vi.hoisted(() => ({
|
||||
mockComputeOrgOverageAmount: vi.fn(),
|
||||
mockIsSubscriptionOrgScoped: vi.fn(),
|
||||
mockGetStampedPeriodRangeUsageCostByUser: vi.fn(),
|
||||
mockComputeDailyRefreshConsumed: vi.fn(),
|
||||
mockEnqueueOutboxEvent: vi.fn(),
|
||||
mockGetPlanPricing: vi.fn(),
|
||||
mockGetPlanTierDollars: vi.fn(),
|
||||
mockResolveSubscriptionUsagePeriod: vi.fn(),
|
||||
mockIsEnterprise: vi.fn(),
|
||||
mockIsFree: vi.fn(),
|
||||
mockRecordAudit: vi.fn(),
|
||||
mockCaptureServerEvent: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@sim/audit', () => ({
|
||||
AuditAction: { OVERAGE_BILLED: 'overage.billed' },
|
||||
AuditResourceType: { BILLING: 'billing' },
|
||||
recordAudit: mockRecordAudit,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/billing/core/billing', () => ({
|
||||
computeOrgOverageAmount: mockComputeOrgOverageAmount,
|
||||
isSubscriptionOrgScoped: mockIsSubscriptionOrgScoped,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/billing/core/reporting-period', () => ({
|
||||
resolveSubscriptionUsagePeriod: mockResolveSubscriptionUsagePeriod,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/billing/core/usage-log', () => ({
|
||||
COPILOT_USAGE_SOURCES: ['copilot'],
|
||||
getStampedPeriodRangeUsageCostByUser: mockGetStampedPeriodRangeUsageCostByUser,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/billing/credits/daily-refresh', () => ({
|
||||
computeDailyRefreshConsumed: mockComputeDailyRefreshConsumed,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/billing/plan-helpers', () => ({
|
||||
getPlanTierDollars: mockGetPlanTierDollars,
|
||||
isEnterprise: mockIsEnterprise,
|
||||
isFree: mockIsFree,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/billing/subscriptions/utils', () => ({
|
||||
ENTITLED_SUBSCRIPTION_STATUSES: ['active', 'past_due'],
|
||||
getPlanPricing: mockGetPlanPricing,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/billing/webhooks/outbox-handlers', () => ({
|
||||
OUTBOX_EVENT_TYPES: {
|
||||
STRIPE_THRESHOLD_OVERAGE_INVOICE: 'stripe.threshold-overage-invoice',
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/core/outbox/service', () => ({
|
||||
enqueueOutboxEvent: mockEnqueueOutboxEvent,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/posthog/server', () => ({
|
||||
captureServerEvent: mockCaptureServerEvent,
|
||||
}))
|
||||
|
||||
import {
|
||||
claimTerminalPeriod,
|
||||
closeElapsedBillingPeriod,
|
||||
closeElapsedPeriodBeforeDeletion,
|
||||
isSubscriptionCycleCloseCurrent,
|
||||
sweepBillingCycleCloses,
|
||||
writeFinalPeriodBookkeeping,
|
||||
} from '@/lib/billing/cycle-close'
|
||||
|
||||
type SubInput = Parameters<typeof closeElapsedBillingPeriod>[0]
|
||||
|
||||
const PERIOD_START = new Date('2026-08-01T00:00:00.000Z')
|
||||
const PREV_PERIOD_START = new Date('2026-07-01T00:00:00.000Z')
|
||||
|
||||
/**
|
||||
* The grace gate and lagging checks compare fixed period boundaries against
|
||||
* `Date.now()`, so the suite pins the clock to stay hermetic on any host date.
|
||||
*/
|
||||
const FROZEN_NOW = new Date('2026-08-15T00:00:00.000Z')
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(FROZEN_NOW)
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
function subRow(overrides: Partial<Record<string, unknown>> = {}): SubInput {
|
||||
return {
|
||||
id: 'sub-1',
|
||||
plan: 'team',
|
||||
referenceId: 'org-1',
|
||||
stripeCustomerId: 'cus_1',
|
||||
stripeSubscriptionId: 'sub_stripe_1',
|
||||
status: 'active',
|
||||
periodStart: PERIOD_START,
|
||||
periodEnd: new Date('2026-09-01T00:00:00.000Z'),
|
||||
billingInterval: 'month',
|
||||
metadata: null,
|
||||
lastClosedPeriodStart: PREV_PERIOD_START,
|
||||
...overrides,
|
||||
} as SubInput
|
||||
}
|
||||
|
||||
/**
|
||||
* Queues the org close's reads in table order: member roster, in-tx member
|
||||
* userStats lock, organization credit row, subscription marker re-read, the
|
||||
* under-lock roster revalidation, and the tracker userStats row.
|
||||
*/
|
||||
function queueOrgCloseReads({
|
||||
members = [{ userId: 'owner-1', role: 'owner' }],
|
||||
orgRow = { creditBalance: '0' },
|
||||
markerRow = { lastClosedPeriodStart: PREV_PERIOD_START },
|
||||
lockedRoster = members,
|
||||
trackerRow = { billedOverageThisPeriod: '0', creditBalance: '0' },
|
||||
}: {
|
||||
members?: { userId: string; role: string }[]
|
||||
orgRow?: Record<string, unknown>
|
||||
markerRow?: Record<string, unknown>
|
||||
lockedRoster?: { userId: string; role: string }[]
|
||||
trackerRow?: Record<string, unknown>
|
||||
} = {}) {
|
||||
queueTableRows(schemaMock.member, members)
|
||||
queueTableRows(schemaMock.userStats, [])
|
||||
queueTableRows(schemaMock.organization, [orgRow])
|
||||
queueTableRows(schemaMock.subscription, [markerRow])
|
||||
queueTableRows(schemaMock.member, lockedRoster)
|
||||
queueTableRows(schemaMock.userStats, [trackerRow])
|
||||
}
|
||||
|
||||
describe('closeElapsedBillingPeriod', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
resetDbChainMock()
|
||||
mockIsSubscriptionOrgScoped.mockResolvedValue(true)
|
||||
mockIsEnterprise.mockReturnValue(false)
|
||||
mockIsFree.mockReturnValue(false)
|
||||
mockResolveSubscriptionUsagePeriod.mockReturnValue(null)
|
||||
mockGetPlanTierDollars.mockReturnValue(40)
|
||||
mockGetPlanPricing.mockReturnValue({ basePrice: 40 })
|
||||
mockComputeDailyRefreshConsumed.mockResolvedValue(0)
|
||||
mockGetStampedPeriodRangeUsageCostByUser.mockResolvedValue(new Map([['owner-1', 150]]))
|
||||
mockComputeOrgOverageAmount.mockResolvedValue({
|
||||
effectiveUsage: 150,
|
||||
baseSubscriptionAmount: 80,
|
||||
dailyRefreshDeduction: 0,
|
||||
totalOverage: 70,
|
||||
})
|
||||
dbChainMockFns.returning.mockResolvedValue([{ id: 'sub-1' }])
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
resetDbChainMock()
|
||||
})
|
||||
|
||||
it('initializes a null marker without billing', async () => {
|
||||
const result = await closeElapsedBillingPeriod(subRow({ lastClosedPeriodStart: null }))
|
||||
|
||||
expect(result.status).toBe('initialized')
|
||||
expect(dbChainMockFns.update).toHaveBeenCalledTimes(1)
|
||||
expect(mockEnqueueOutboxEvent).not.toHaveBeenCalled()
|
||||
expect(mockGetStampedPeriodRangeUsageCostByUser).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns current when the marker already matches the period start', async () => {
|
||||
const result = await closeElapsedBillingPeriod(subRow({ lastClosedPeriodStart: PERIOD_START }))
|
||||
|
||||
expect(result.status).toBe('current')
|
||||
expect(dbChainMockFns.update).not.toHaveBeenCalled()
|
||||
expect(mockEnqueueOutboxEvent).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('closes a team period: bills the remainder, resets trackers, and claims the marker', async () => {
|
||||
queueOrgCloseReads()
|
||||
|
||||
const result = await closeElapsedBillingPeriod(subRow())
|
||||
|
||||
expect(result.status).toBe('closed')
|
||||
expect(result.overageBilled).toBe(70)
|
||||
|
||||
expect(mockComputeOrgOverageAmount).toHaveBeenCalledWith({
|
||||
plan: 'team',
|
||||
seats: null,
|
||||
periodStart: PREV_PERIOD_START,
|
||||
periodEnd: PERIOD_START,
|
||||
organizationId: 'org-1',
|
||||
pooledLedgerUsage: 150,
|
||||
})
|
||||
|
||||
expect(mockEnqueueOutboxEvent).toHaveBeenCalledTimes(1)
|
||||
const [, eventType, payload] = mockEnqueueOutboxEvent.mock.calls[0]
|
||||
expect(eventType).toBe('stripe.threshold-overage-invoice')
|
||||
expect(payload).toMatchObject({
|
||||
customerId: 'cus_1',
|
||||
stripeSubscriptionId: 'sub_stripe_1',
|
||||
amountCents: 7000,
|
||||
invoiceIdemKeyStem: `cycle-close-overage:sub-1:${PERIOD_START.toISOString()}:invoice`,
|
||||
metadata: expect.objectContaining({ type: 'overage_billing', organizationId: 'org-1' }),
|
||||
})
|
||||
|
||||
// Bookkeeping: last-period CASE write + billedOverage reset on member rows.
|
||||
const bookkeepingSet = dbChainMockFns.set.mock.calls.find(
|
||||
(call) => (call[0] as Record<string, unknown>).billedOverageThisPeriod === '0'
|
||||
)?.[0] as Record<string, unknown>
|
||||
expect(bookkeepingSet).toBeDefined()
|
||||
expect(
|
||||
(bookkeepingSet.lastPeriodCost as { toSQL?: () => { sql: string } })?.toSQL?.().sql
|
||||
).toContain('CASE')
|
||||
|
||||
// Marker claim committed in the same transaction.
|
||||
const markerSet = dbChainMockFns.set.mock.calls.find(
|
||||
(call) => (call[0] as Record<string, unknown>).lastClosedPeriodStart instanceof Date
|
||||
)
|
||||
expect(markerSet).toBeDefined()
|
||||
expect(dbChainMockFns.transaction).toHaveBeenCalledTimes(1)
|
||||
expect(mockRecordAudit).toHaveBeenCalledTimes(1)
|
||||
expect(mockCaptureServerEvent).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('defers the close inside the settlement grace after a rollover', async () => {
|
||||
// A run whose frozen attribution predates the rollover could still insert
|
||||
// elapsed-period rows; the close waits until sums are final.
|
||||
const result = await closeElapsedBillingPeriod(
|
||||
subRow({
|
||||
periodStart: new Date(Date.now() - 60_000),
|
||||
lastClosedPeriodStart: new Date(Date.now() - 60_000 - 31 * 24 * 60 * 60 * 1000),
|
||||
})
|
||||
)
|
||||
|
||||
expect(result.status).toBe('skipped')
|
||||
expect(dbChainMockFns.transaction).not.toHaveBeenCalled()
|
||||
expect(mockGetStampedPeriodRangeUsageCostByUser).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('defers the close when overage is due but Stripe identifiers are missing', async () => {
|
||||
const result = await closeElapsedBillingPeriod(subRow({ stripeCustomerId: null }))
|
||||
|
||||
expect(result.status).toBe('skipped')
|
||||
// No marker claim, no money, no bookkeeping — the sweep retries next run.
|
||||
expect(dbChainMockFns.transaction).not.toHaveBeenCalled()
|
||||
expect(dbChainMockFns.update).not.toHaveBeenCalled()
|
||||
expect(mockEnqueueOutboxEvent).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('derives the closed window from the ledger period stamps when they drift from calendar math', async () => {
|
||||
// Rows for the elapsed period are stamped starting Jul 3 (anchor drift)
|
||||
// while the marker sits at Jul 1: only the stamp lookup can produce the
|
||||
// Jul 3 bound — calendar math (periodStart minus one interval) would keep
|
||||
// the window at Jul 1.
|
||||
const stampedPrevStart = new Date('2026-07-03T00:00:00.000Z')
|
||||
queueTableRows(schemaMock.usageLog, [{ start: stampedPrevStart }])
|
||||
queueOrgCloseReads()
|
||||
|
||||
await closeElapsedBillingPeriod(subRow({ lastClosedPeriodStart: PREV_PERIOD_START }))
|
||||
|
||||
expect(mockComputeOrgOverageAmount).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ periodStart: stampedPrevStart, periodEnd: PERIOD_START })
|
||||
)
|
||||
})
|
||||
|
||||
it('defers the close when overage is due but the organization has no owner', async () => {
|
||||
mockGetStampedPeriodRangeUsageCostByUser.mockResolvedValue(new Map([['departed-1', 150]]))
|
||||
// Member roster has no owner-role row.
|
||||
queueTableRows(schemaMock.member, [{ userId: 'member-1', role: 'member' }])
|
||||
|
||||
const result = await closeElapsedBillingPeriod(subRow())
|
||||
|
||||
expect(result.status).toBe('skipped')
|
||||
expect(dbChainMockFns.transaction).not.toHaveBeenCalled()
|
||||
expect(mockEnqueueOutboxEvent).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('bills departed members through the pooled entity sums, not a roster', async () => {
|
||||
// 'departed-1' has org-attributed rows in the closed period but no member
|
||||
// row anymore; the pooled sum carries them, and the entity-scoped refresh
|
||||
// inside computeOrgOverageAmount offsets them identically — no actor list
|
||||
// is passed anywhere.
|
||||
mockGetStampedPeriodRangeUsageCostByUser.mockResolvedValue(
|
||||
new Map([
|
||||
['owner-1', 100],
|
||||
['departed-1', 50],
|
||||
])
|
||||
)
|
||||
queueOrgCloseReads()
|
||||
|
||||
await closeElapsedBillingPeriod(subRow())
|
||||
|
||||
expect(mockComputeOrgOverageAmount).toHaveBeenCalledWith({
|
||||
plan: 'team',
|
||||
seats: null,
|
||||
periodStart: PREV_PERIOD_START,
|
||||
periodEnd: PERIOD_START,
|
||||
organizationId: 'org-1',
|
||||
pooledLedgerUsage: 150,
|
||||
})
|
||||
})
|
||||
|
||||
it('subtracts the current period tracker from the final overage', async () => {
|
||||
queueOrgCloseReads({ trackerRow: { billedOverageThisPeriod: '30', creditBalance: '0' } })
|
||||
|
||||
const result = await closeElapsedBillingPeriod(subRow())
|
||||
|
||||
expect(result.status).toBe('closed')
|
||||
expect(result.overageBilled).toBe(40)
|
||||
const [, , payload] = mockEnqueueOutboxEvent.mock.calls[0]
|
||||
expect(payload).toMatchObject({ amountCents: 4000 })
|
||||
})
|
||||
|
||||
it('ignores the stale tracker when the close skipped forgiven periods', async () => {
|
||||
// Marker two intervals back: the close forgives the older period and
|
||||
// bills [Jul 1, Aug 1) only. The tracker's collections belong to the
|
||||
// period that began at the marker, so none of them offset this close.
|
||||
const staleMarker = new Date('2026-05-01T00:00:00.000Z')
|
||||
queueOrgCloseReads({
|
||||
markerRow: { lastClosedPeriodStart: staleMarker },
|
||||
trackerRow: { billedOverageThisPeriod: '30', creditBalance: '0' },
|
||||
})
|
||||
|
||||
const result = await closeElapsedBillingPeriod(subRow({ lastClosedPeriodStart: staleMarker }))
|
||||
|
||||
expect(result.status).toBe('closed')
|
||||
expect(result.overageBilled).toBe(70)
|
||||
const [, , payload] = mockEnqueueOutboxEvent.mock.calls[0]
|
||||
expect(payload).toMatchObject({ amountCents: 7000 })
|
||||
})
|
||||
|
||||
it('applies organization credits before invoicing and skips Stripe when covered', async () => {
|
||||
queueOrgCloseReads({ orgRow: { creditBalance: '100' } })
|
||||
|
||||
const result = await closeElapsedBillingPeriod(subRow())
|
||||
|
||||
expect(result.status).toBe('closed')
|
||||
expect(result.creditsApplied).toBe(70)
|
||||
expect(result.overageBilled).toBe(0)
|
||||
expect(mockEnqueueOutboxEvent).not.toHaveBeenCalled()
|
||||
expect(mockRecordAudit).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('subtracts overage already collected by threshold billing', async () => {
|
||||
queueOrgCloseReads({ trackerRow: { billedOverageThisPeriod: '70', creditBalance: '0' } })
|
||||
|
||||
const result = await closeElapsedBillingPeriod(subRow())
|
||||
|
||||
expect(result.status).toBe('closed')
|
||||
expect(result.overageBilled).toBe(0)
|
||||
expect(mockEnqueueOutboxEvent).not.toHaveBeenCalled()
|
||||
expect(mockRecordAudit).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('defers when the organization roster changed between preflight and the locked transaction', async () => {
|
||||
queueOrgCloseReads({
|
||||
lockedRoster: [
|
||||
{ userId: 'owner-1', role: 'member' },
|
||||
{ userId: 'member-2', role: 'owner' },
|
||||
],
|
||||
})
|
||||
|
||||
const result = await closeElapsedBillingPeriod(subRow())
|
||||
|
||||
expect(result.status).toBe('skipped')
|
||||
expect(mockEnqueueOutboxEvent).not.toHaveBeenCalled()
|
||||
expect(dbChainMockFns.update).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('no-ops when a concurrent closer already advanced the marker', async () => {
|
||||
queueOrgCloseReads({ markerRow: { lastClosedPeriodStart: PERIOD_START } })
|
||||
|
||||
const result = await closeElapsedBillingPeriod(subRow())
|
||||
|
||||
expect(result.status).toBe('already-closed')
|
||||
expect(mockEnqueueOutboxEvent).not.toHaveBeenCalled()
|
||||
expect(dbChainMockFns.update).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('books enterprise periods without collecting money', async () => {
|
||||
mockIsEnterprise.mockReturnValue(true)
|
||||
queueOrgCloseReads()
|
||||
|
||||
const result = await closeElapsedBillingPeriod(subRow({ plan: 'enterprise' }))
|
||||
|
||||
expect(result.status).toBe('closed')
|
||||
expect(result.overageBilled).toBe(0)
|
||||
expect(mockComputeOrgOverageAmount).not.toHaveBeenCalled()
|
||||
expect(mockEnqueueOutboxEvent).not.toHaveBeenCalled()
|
||||
// Bookkeeping still writes last-period sums.
|
||||
const bookkeepingSet = dbChainMockFns.set.mock.calls.find(
|
||||
(call) => (call[0] as Record<string, unknown>).billedOverageThisPeriod === '0'
|
||||
)
|
||||
expect(bookkeepingSet).toBeDefined()
|
||||
})
|
||||
|
||||
it('only advances the marker for enterprise orgs on reporting anchors', async () => {
|
||||
mockIsEnterprise.mockReturnValue(true)
|
||||
mockResolveSubscriptionUsagePeriod.mockReturnValue({ source: 'reporting' })
|
||||
|
||||
const result = await closeElapsedBillingPeriod(
|
||||
subRow({ plan: 'enterprise', metadata: { reportingPeriodAnchorDate: '2026-05-01' } })
|
||||
)
|
||||
|
||||
expect(result.status).toBe('closed')
|
||||
expect(mockGetStampedPeriodRangeUsageCostByUser).not.toHaveBeenCalled()
|
||||
expect(mockEnqueueOutboxEvent).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('closes a personal subscription against the user ledger', async () => {
|
||||
mockIsSubscriptionOrgScoped.mockResolvedValue(false)
|
||||
mockGetStampedPeriodRangeUsageCostByUser.mockResolvedValue(new Map([['user-1', 90]]))
|
||||
// Personal reads: in-tx userStats lock, marker re-read, tracker row.
|
||||
queueTableRows(schemaMock.userStats, [])
|
||||
queueTableRows(schemaMock.subscription, [{ lastClosedPeriodStart: PREV_PERIOD_START }])
|
||||
queueTableRows(schemaMock.userStats, [{ billedOverageThisPeriod: '0', creditBalance: '0' }])
|
||||
|
||||
const result = await closeElapsedBillingPeriod(
|
||||
subRow({ plan: 'pro', referenceId: 'user-1', stripeCustomerId: 'cus_user' })
|
||||
)
|
||||
|
||||
expect(result.status).toBe('closed')
|
||||
// 90 ledger - 0 refresh - 40 base = 50 overage
|
||||
expect(result.overageBilled).toBe(50)
|
||||
expect(mockComputeOrgOverageAmount).not.toHaveBeenCalled()
|
||||
expect(mockEnqueueOutboxEvent).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('writeFinalPeriodBookkeeping', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
resetDbChainMock()
|
||||
mockIsSubscriptionOrgScoped.mockResolvedValue(true)
|
||||
mockIsEnterprise.mockReturnValue(false)
|
||||
mockResolveSubscriptionUsagePeriod.mockReturnValue(null)
|
||||
mockGetStampedPeriodRangeUsageCostByUser.mockResolvedValue(new Map([['owner-1', 25]]))
|
||||
dbChainMockFns.returning.mockResolvedValue([{ id: 'sub-1' }])
|
||||
})
|
||||
|
||||
it('resets trackers and writes last-period sums in one transaction', async () => {
|
||||
queueTableRows(schemaMock.member, [{ userId: 'owner-1' }])
|
||||
|
||||
await writeFinalPeriodBookkeeping({
|
||||
id: 'sub-1',
|
||||
plan: 'team',
|
||||
referenceId: 'org-1',
|
||||
periodStart: PERIOD_START,
|
||||
periodEnd: new Date('2026-09-01T00:00:00.000Z'),
|
||||
})
|
||||
|
||||
expect(dbChainMockFns.transaction).toHaveBeenCalledTimes(1)
|
||||
const bookkeepingSet = dbChainMockFns.set.mock.calls.find(
|
||||
(call) => (call[0] as Record<string, unknown>).billedOverageThisPeriod === '0'
|
||||
)
|
||||
expect(bookkeepingSet).toBeDefined()
|
||||
})
|
||||
|
||||
it('is a no-op for reporting-anchor enterprise subscriptions', async () => {
|
||||
mockIsEnterprise.mockReturnValue(true)
|
||||
mockResolveSubscriptionUsagePeriod.mockReturnValue({ source: 'reporting' })
|
||||
|
||||
await writeFinalPeriodBookkeeping({
|
||||
id: 'sub-1',
|
||||
plan: 'enterprise',
|
||||
referenceId: 'org-1',
|
||||
periodStart: PERIOD_START,
|
||||
periodEnd: new Date('2026-09-01T00:00:00.000Z'),
|
||||
metadata: { reportingPeriodAnchorDate: '2026-05-01' },
|
||||
})
|
||||
|
||||
expect(mockGetStampedPeriodRangeUsageCostByUser).not.toHaveBeenCalled()
|
||||
expect(dbChainMockFns.transaction).not.toHaveBeenCalled()
|
||||
expect(dbChainMockFns.set).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('closeElapsedPeriodBeforeDeletion', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
resetDbChainMock()
|
||||
mockIsSubscriptionOrgScoped.mockResolvedValue(true)
|
||||
mockIsEnterprise.mockReturnValue(false)
|
||||
mockIsFree.mockReturnValue(false)
|
||||
mockResolveSubscriptionUsagePeriod.mockReturnValue(null)
|
||||
mockGetPlanTierDollars.mockReturnValue(40)
|
||||
mockGetPlanPricing.mockReturnValue({ basePrice: 40 })
|
||||
mockComputeDailyRefreshConsumed.mockResolvedValue(0)
|
||||
mockGetStampedPeriodRangeUsageCostByUser.mockResolvedValue(new Map([['owner-1', 150]]))
|
||||
mockComputeOrgOverageAmount.mockResolvedValue({
|
||||
effectiveUsage: 150,
|
||||
baseSubscriptionAmount: 80,
|
||||
dailyRefreshDeduction: 0,
|
||||
totalOverage: 70,
|
||||
})
|
||||
dbChainMockFns.returning.mockResolvedValue([{ id: 'sub-1' }])
|
||||
})
|
||||
|
||||
it('closes a lagging period with the settlement grace bypassed', async () => {
|
||||
// Rollover 30 minutes ago — inside the grace the sweep would honor. The
|
||||
// deletion path cannot wait: no later sweep revisits a canceled sub, so
|
||||
// it settles the elapsed period now with whatever rows have landed.
|
||||
const recentRollover = new Date(Date.now() - 30 * 60 * 1000)
|
||||
queueTableRows(schemaMock.subscription, [subRow({ periodStart: recentRollover })])
|
||||
queueOrgCloseReads()
|
||||
|
||||
await closeElapsedPeriodBeforeDeletion('sub-1')
|
||||
|
||||
expect(mockEnqueueOutboxEvent).toHaveBeenCalledTimes(1)
|
||||
const markerSet = dbChainMockFns.set.mock.calls.find(
|
||||
(call) => (call[0] as Record<string, unknown>).lastClosedPeriodStart instanceof Date
|
||||
)
|
||||
expect(markerSet).toBeDefined()
|
||||
})
|
||||
|
||||
it('no-ops when the close marker is already current', async () => {
|
||||
queueTableRows(schemaMock.subscription, [subRow({ lastClosedPeriodStart: PERIOD_START })])
|
||||
|
||||
await closeElapsedPeriodBeforeDeletion('sub-1')
|
||||
|
||||
expect(dbChainMockFns.transaction).not.toHaveBeenCalled()
|
||||
expect(mockEnqueueOutboxEvent).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('claimTerminalPeriod', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
resetDbChainMock()
|
||||
dbChainMockFns.returning.mockResolvedValue([{ id: 'sub-1' }])
|
||||
})
|
||||
|
||||
it('returns the fresh period without rewriting a current marker', async () => {
|
||||
queueTableRows(schemaMock.subscription, [
|
||||
{
|
||||
periodStart: PERIOD_START,
|
||||
periodEnd: new Date('2026-09-01T00:00:00.000Z'),
|
||||
lastClosedPeriodStart: PERIOD_START,
|
||||
},
|
||||
])
|
||||
|
||||
const terminal = await claimTerminalPeriod('sub-1')
|
||||
|
||||
expect(terminal.periodStart).toEqual(PERIOD_START)
|
||||
expect(terminal.markerWasCurrent).toBe(true)
|
||||
expect(dbChainMockFns.set).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports a lagging marker without jumping it, so the caller can close and re-claim', async () => {
|
||||
queueTableRows(schemaMock.subscription, [
|
||||
{
|
||||
periodStart: PERIOD_START,
|
||||
periodEnd: new Date('2026-09-01T00:00:00.000Z'),
|
||||
lastClosedPeriodStart: PREV_PERIOD_START,
|
||||
},
|
||||
])
|
||||
|
||||
const terminal = await claimTerminalPeriod('sub-1')
|
||||
|
||||
expect(terminal.markerWasCurrent).toBe(false)
|
||||
expect(dbChainMockFns.set).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('seals a lagging marker on request, forgiving the unclosed period loudly', async () => {
|
||||
queueTableRows(schemaMock.subscription, [
|
||||
{
|
||||
periodStart: PERIOD_START,
|
||||
periodEnd: new Date('2026-09-01T00:00:00.000Z'),
|
||||
lastClosedPeriodStart: PREV_PERIOD_START,
|
||||
},
|
||||
])
|
||||
|
||||
const terminal = await claimTerminalPeriod('sub-1', { sealLagging: true })
|
||||
|
||||
expect(terminal.markerWasCurrent).toBe(false)
|
||||
const markerSet = dbChainMockFns.set.mock.calls.find(
|
||||
(call) => (call[0] as Record<string, unknown>).lastClosedPeriodStart instanceof Date
|
||||
)
|
||||
expect(markerSet).toBeDefined()
|
||||
})
|
||||
|
||||
it('returns nulls without claiming when the subscription has no period', async () => {
|
||||
queueTableRows(schemaMock.subscription, [{ periodStart: null, periodEnd: null }])
|
||||
|
||||
const terminal = await claimTerminalPeriod('sub-1')
|
||||
|
||||
expect(terminal).toEqual({ periodStart: null, periodEnd: null, markerWasCurrent: true })
|
||||
expect(dbChainMockFns.set).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('isSubscriptionCycleCloseCurrent', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
resetDbChainMock()
|
||||
})
|
||||
|
||||
it('is current when the marker has caught up to the period start', async () => {
|
||||
queueTableRows(schemaMock.subscription, [
|
||||
{ periodStart: PERIOD_START, lastClosedPeriodStart: PERIOD_START },
|
||||
])
|
||||
await expect(isSubscriptionCycleCloseCurrent('sub-1')).resolves.toBe(true)
|
||||
})
|
||||
|
||||
it('is pending when the marker lags the period start or was never initialized', async () => {
|
||||
queueTableRows(schemaMock.subscription, [
|
||||
{ periodStart: PERIOD_START, lastClosedPeriodStart: PREV_PERIOD_START },
|
||||
])
|
||||
await expect(isSubscriptionCycleCloseCurrent('sub-1')).resolves.toBe(false)
|
||||
|
||||
queueTableRows(schemaMock.subscription, [
|
||||
{ periodStart: PERIOD_START, lastClosedPeriodStart: null },
|
||||
])
|
||||
await expect(isSubscriptionCycleCloseCurrent('sub-1')).resolves.toBe(false)
|
||||
})
|
||||
|
||||
it('is current when the subscription has no period to close', async () => {
|
||||
queueTableRows(schemaMock.subscription, [{ periodStart: null, lastClosedPeriodStart: null }])
|
||||
await expect(isSubscriptionCycleCloseCurrent('sub-1')).resolves.toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('sweepBillingCycleCloses', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
resetDbChainMock()
|
||||
mockIsFree.mockReturnValue(false)
|
||||
mockIsEnterprise.mockReturnValue(false)
|
||||
})
|
||||
|
||||
it('initializes every candidate with a lagging marker', async () => {
|
||||
// Both rows are shaped like rows the sweep's candidate query can actually
|
||||
// return: entitled, non-null periodStart, marker lagging (null).
|
||||
queueTableRows(schemaMock.subscription, [
|
||||
subRow({ id: 'sub-a', lastClosedPeriodStart: null }),
|
||||
subRow({ id: 'sub-b', lastClosedPeriodStart: null }),
|
||||
])
|
||||
|
||||
const summary = await sweepBillingCycleCloses()
|
||||
|
||||
expect(summary.candidates).toBe(2)
|
||||
expect(summary.initialized).toBe(2)
|
||||
expect(summary.failed).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,852 @@
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
|
||||
import { db } from '@sim/db'
|
||||
import {
|
||||
member,
|
||||
organization,
|
||||
subscription as subscriptionTable,
|
||||
usageLog,
|
||||
userStats,
|
||||
} from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { and, eq, inArray, isNull, lt, or, sql } from 'drizzle-orm'
|
||||
import { BILLING_LOCK_TIMEOUT_MS } from '@/lib/billing/constants'
|
||||
import { computeOrgOverageAmount, isSubscriptionOrgScoped } from '@/lib/billing/core/billing'
|
||||
import { resolveSubscriptionUsagePeriod } from '@/lib/billing/core/reporting-period'
|
||||
import {
|
||||
COPILOT_USAGE_SOURCES,
|
||||
getStampedPeriodRangeUsageCostByUser,
|
||||
} from '@/lib/billing/core/usage-log'
|
||||
import { computeDailyRefreshConsumed } from '@/lib/billing/credits/daily-refresh'
|
||||
import { getPlanTierDollars, isEnterprise, isFree } from '@/lib/billing/plan-helpers'
|
||||
import { ENTITLED_SUBSCRIPTION_STATUSES, getPlanPricing } from '@/lib/billing/subscriptions/utils'
|
||||
import { toDecimal, toNumber } from '@/lib/billing/utils/decimal'
|
||||
import { OUTBOX_EVENT_TYPES } from '@/lib/billing/webhooks/outbox-handlers'
|
||||
import { enqueueOutboxEvent } from '@/lib/core/outbox/service'
|
||||
import type { DbOrTx } from '@/lib/db/types'
|
||||
import { captureServerEvent } from '@/lib/posthog/server'
|
||||
|
||||
const logger = createLogger('BillingCycleClose')
|
||||
|
||||
/**
|
||||
* Minimum residual overage worth invoicing at cycle close, in dollars.
|
||||
* Anything below this is forgiven rather than billed as a sub-cent invoice.
|
||||
*/
|
||||
const MIN_CLOSE_INVOICE_DOLLARS = 0.5
|
||||
|
||||
/**
|
||||
* Settlement grace after a rollover before its elapsed period may close.
|
||||
* Billing attribution is frozen at run start (the payer is immutable for the
|
||||
* run), so a run that started just before the rollover can insert rows
|
||||
* stamped with the elapsed period after it ends. Closing only once the
|
||||
* rollover is older than any possible in-flight run guarantees the close's
|
||||
* ledger sums are final — no straggler row is orphaned from the final
|
||||
* overage or bookkeeping. Non-enterprise execution timeouts are far below
|
||||
* this bound; the sweep simply picks the period up on a later run.
|
||||
*/
|
||||
const CLOSE_SETTLEMENT_GRACE_MS = 60 * 60 * 1000
|
||||
|
||||
type SubscriptionRow = typeof subscriptionTable.$inferSelect
|
||||
|
||||
export type CycleCloseStatus = 'initialized' | 'current' | 'closed' | 'already-closed' | 'skipped'
|
||||
|
||||
export interface CycleCloseResult {
|
||||
status: CycleCloseStatus
|
||||
subscriptionId: string
|
||||
overageBilled?: number
|
||||
creditsApplied?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Subtract one billing interval from a period boundary. Mirrors Stripe's
|
||||
* anchor-day semantics closely enough for a close window: the ledger rows are
|
||||
* matched by their write-time period stamps, so this bound only needs to
|
||||
* enclose the closed period, not reproduce it exactly.
|
||||
*/
|
||||
function minusOneInterval(date: Date, billingInterval: string | null): Date {
|
||||
const result = new Date(date.getTime())
|
||||
if (billingInterval === 'year') {
|
||||
result.setUTCFullYear(result.getUTCFullYear() - 1)
|
||||
} else {
|
||||
result.setUTCMonth(result.getUTCMonth() - 1)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/** Order-insensitive membership fingerprint for under-lock roster revalidation. */
|
||||
function rosterSignature(rows: { userId: string; role: string }[]): string {
|
||||
return rows
|
||||
.map((row) => `${row.userId}:${row.role}`)
|
||||
.sort()
|
||||
.join('|')
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this subscription's usage windows derive from an enterprise
|
||||
* reporting anchor. Asks the same resolver the usage math uses, so a
|
||||
* malformed anchor (hand-edited Stripe metadata) that the resolver rejects —
|
||||
* falling back to Stripe bounds — is treated identically here: the ledger
|
||||
* rows are stamped with Stripe windows, and the close books them normally.
|
||||
*/
|
||||
function usesReportingWindows(sub: {
|
||||
plan?: string | null
|
||||
billingInterval?: string | null
|
||||
metadata?: unknown
|
||||
periodStart?: Date | null
|
||||
periodEnd?: Date | null
|
||||
}): boolean {
|
||||
return resolveSubscriptionUsagePeriod(sub)?.source === 'reporting'
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a subscription's previous period has already been closed — i.e. the
|
||||
* durable close marker has caught up to the current `periodStart`.
|
||||
*
|
||||
* Threshold billing gates on this so the shared `billedOverageThisPeriod`
|
||||
* tracker never mixes periods: after a rollover but before the sweep closes
|
||||
* the elapsed period, a new-period settlement would be subtracted from the
|
||||
* elapsed period's final overage and then wiped by the close's tracker reset,
|
||||
* under-billing one period and double-billing the other. Skipping settlement
|
||||
* until the close lands (sweep cadence, ≤6h) removes the race; a null marker
|
||||
* (pre-first-sweep) also gates, and a null `periodStart` cannot race at all.
|
||||
*
|
||||
* The same predicate revalidates inside the settlement transaction (pass the
|
||||
* `tx` as `executor` plus the `expectedPeriodStart` the overage was computed
|
||||
* against): the unlocked preflight leaves a window where a rollover and its
|
||||
* close can commit first, so the settlement re-checks under the tracker lock
|
||||
* and aborts when the period moved.
|
||||
*/
|
||||
export async function isSubscriptionCycleCloseCurrent(
|
||||
subscriptionId: string,
|
||||
options: { executor?: DbOrTx; expectedPeriodStart?: Date | null } = {}
|
||||
): Promise<boolean> {
|
||||
const executor = options.executor ?? db
|
||||
const [row] = await executor
|
||||
.select({
|
||||
periodStart: subscriptionTable.periodStart,
|
||||
lastClosedPeriodStart: subscriptionTable.lastClosedPeriodStart,
|
||||
})
|
||||
.from(subscriptionTable)
|
||||
.where(eq(subscriptionTable.id, subscriptionId))
|
||||
.limit(1)
|
||||
|
||||
if (options.expectedPeriodStart) {
|
||||
if (!row?.periodStart || row.periodStart.getTime() !== options.expectedPeriodStart.getTime()) {
|
||||
return false
|
||||
}
|
||||
} else if (!row?.periodStart) {
|
||||
return true
|
||||
}
|
||||
|
||||
return (
|
||||
row.lastClosedPeriodStart !== null &&
|
||||
row.lastClosedPeriodStart.getTime() >= row.periodStart.getTime()
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Close any elapsed-but-unclosed billing period for a subscription that is
|
||||
* being deleted, ahead of its terminal settlement. A deleted subscription
|
||||
* leaves the sweep's candidate set (its status leaves
|
||||
* `ENTITLED_SUBSCRIPTION_STATUSES`), so this is the last chance to settle a
|
||||
* period the sweep has not caught up to — without it, `claimTerminalPeriod`
|
||||
* would advance the marker past the elapsed period and silently forgive its
|
||||
* final overage, while the elapsed period's threshold collections would
|
||||
* wrongly offset the terminal window's. The settlement grace is bypassed:
|
||||
* no later sweep will revisit, so straggler rows are forgiven exactly like
|
||||
* the terminal settlement's own.
|
||||
*/
|
||||
export async function closeElapsedPeriodBeforeDeletion(subscriptionId: string): Promise<void> {
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(subscriptionTable)
|
||||
.where(eq(subscriptionTable.id, subscriptionId))
|
||||
.limit(1)
|
||||
if (!row?.periodStart) return
|
||||
const lagging =
|
||||
row.lastClosedPeriodStart === null ||
|
||||
row.lastClosedPeriodStart.getTime() < row.periodStart.getTime()
|
||||
if (!lagging) return
|
||||
|
||||
const result = await closeElapsedBillingPeriod(row, { bypassSettlementGrace: true })
|
||||
if (result.status === 'skipped') {
|
||||
// The close deferred (missing Stripe linkage, ownerless org, or a roster
|
||||
// change mid-close). Deletion proceeds — blocking member downgrades on an
|
||||
// unbillable period is the wrong trade — so the residual overage is
|
||||
// forgiven; the close path already logged the specific cause.
|
||||
logger.error(
|
||||
'Deletion proceeding past an unclosable elapsed period; residual overage forgiven',
|
||||
{
|
||||
subscriptionId,
|
||||
plan: row.plan,
|
||||
marker: row.lastClosedPeriodStart?.toISOString() ?? null,
|
||||
periodStart: row.periodStart.toISOString(),
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Claim the terminal period for a subscription that is being deleted, BEFORE
|
||||
* the deletion handler computes and charges final overage. Reads the
|
||||
* subscription row fresh (webhook payloads can be stale across a rollover)
|
||||
* and advances the close marker to its current `periodStart` in one
|
||||
* transaction, serializing with the sweep on the subscription row: an
|
||||
* in-flight sweep close then fails its guarded marker claim and rolls back —
|
||||
* including its outbox invoice — so deletion and sweep can never both bill
|
||||
* the same period. Call `closeElapsedPeriodBeforeDeletion` first so a lagging
|
||||
* elapsed period is settled rather than jumped. Returns the fresh period
|
||||
* bounds for the deletion flow to settle against, plus `markerWasCurrent`:
|
||||
* whether the close marker had already caught up to the terminal period.
|
||||
* The `billedOverageThisPeriod` tracker only ever holds collections for the
|
||||
* period that began at the marker (the threshold gate blocks settlement
|
||||
* whenever the marker lags), so the terminal settlement must ignore the
|
||||
* tracker when the marker was still lagging — its contents belong to a
|
||||
* forgiven elapsed period, never to the terminal window.
|
||||
*
|
||||
* A lagging marker means an elapsed period is still unclosed — either the
|
||||
* preceding close deferred, or a rollover committed between that close and
|
||||
* this claim. By default the claim then leaves the marker untouched so the
|
||||
* caller can run the close again and re-claim; `sealLagging` advances the
|
||||
* marker over the unclosed period anyway (logging the forgiveness), which
|
||||
* also guarantees an in-flight sweep that selected this subscription before
|
||||
* its status changed aborts its own conflicting close.
|
||||
*/
|
||||
export async function claimTerminalPeriod(
|
||||
subscriptionId: string,
|
||||
options: { sealLagging?: boolean } = {}
|
||||
): Promise<{
|
||||
periodStart: Date | null
|
||||
periodEnd: Date | null
|
||||
markerWasCurrent: boolean
|
||||
}> {
|
||||
return db.transaction(async (tx) => {
|
||||
const [row] = await tx
|
||||
.select({
|
||||
periodStart: subscriptionTable.periodStart,
|
||||
periodEnd: subscriptionTable.periodEnd,
|
||||
lastClosedPeriodStart: subscriptionTable.lastClosedPeriodStart,
|
||||
})
|
||||
.from(subscriptionTable)
|
||||
.where(eq(subscriptionTable.id, subscriptionId))
|
||||
.for('update')
|
||||
.limit(1)
|
||||
|
||||
if (!row?.periodStart) {
|
||||
// Mirrors the threshold gate: a null `periodStart` cannot race a
|
||||
// rollover, so any tracked collections are legitimately current.
|
||||
return { periodStart: null, periodEnd: null, markerWasCurrent: true }
|
||||
}
|
||||
const markerWasCurrent =
|
||||
!!row.lastClosedPeriodStart &&
|
||||
row.lastClosedPeriodStart.getTime() >= row.periodStart.getTime()
|
||||
if (!markerWasCurrent && options.sealLagging) {
|
||||
logger.error(
|
||||
'Sealing an unclosed elapsed period at terminal claim; residual overage forgiven',
|
||||
{
|
||||
subscriptionId,
|
||||
marker: row.lastClosedPeriodStart?.toISOString() ?? null,
|
||||
periodStart: row.periodStart.toISOString(),
|
||||
}
|
||||
)
|
||||
await claimCloseMarker(tx, subscriptionId, row.periodStart)
|
||||
}
|
||||
return { periodStart: row.periodStart, periodEnd: row.periodEnd, markerWasCurrent }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Advance the durable close marker to `periodStart`, guarded so concurrent
|
||||
* closers and replays collapse to one winner. Returns false when another
|
||||
* worker already advanced the marker at or past this boundary.
|
||||
*/
|
||||
async function claimCloseMarker(
|
||||
tx: Parameters<Parameters<typeof db.transaction>[0]>[0],
|
||||
subscriptionId: string,
|
||||
periodStart: Date
|
||||
): Promise<boolean> {
|
||||
const claimed = await tx
|
||||
.update(subscriptionTable)
|
||||
.set({ lastClosedPeriodStart: periodStart })
|
||||
.where(
|
||||
and(
|
||||
eq(subscriptionTable.id, subscriptionId),
|
||||
or(
|
||||
isNull(subscriptionTable.lastClosedPeriodStart),
|
||||
lt(subscriptionTable.lastClosedPeriodStart, periodStart)
|
||||
)
|
||||
)
|
||||
)
|
||||
.returning({ id: subscriptionTable.id })
|
||||
return claimed.length > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the most recently elapsed billing period for one subscription.
|
||||
*
|
||||
* Runs when the durable `lastClosedPeriodStart` marker lags the subscription's
|
||||
* current `periodStart` (better-auth advances the row's period from Stripe's
|
||||
* `customer.subscription.updated`). The close, per closed period:
|
||||
*
|
||||
* 1. Sums the closed period's ledger usage per member from write-time period
|
||||
* stamps (`getStampedPeriodRangeUsageCostByUser`) — never `created_at`.
|
||||
* 2. Collects final sub-threshold overage for non-enterprise plans: computed
|
||||
* overage minus what threshold billing already collected
|
||||
* (`billedOverageThisPeriod`), credits applied first, remainder invoiced
|
||||
* through the transaction-enlisted Stripe outbox with deterministic
|
||||
* idempotency stems keyed by `(subscriptionId, closed period)`.
|
||||
* 3. Writes `lastPeriodCost` / `lastPeriodCopilotCost` bookkeeping from the
|
||||
* same ledger sums and resets `billedOverageThisPeriod` for the new period.
|
||||
* 4. Advances the marker in the same transaction, so the money, bookkeeping,
|
||||
* and marker commit atomically — a crash retries the whole close, and the
|
||||
* outbox's Stripe idempotency keys collapse invoice replays.
|
||||
*
|
||||
* Enterprise subscriptions never collect money here (billing is contractual,
|
||||
* outside Stripe); they get bookkeeping + marker only, and orgs on reporting
|
||||
* anchors skip bookkeeping too because their windows derive live from the
|
||||
* anchor. A null marker initializes to the current `periodStart` without
|
||||
* billing, so historical periods are never retroactively closed.
|
||||
*/
|
||||
export async function closeElapsedBillingPeriod(
|
||||
sub: SubscriptionRow,
|
||||
options: { bypassSettlementGrace?: boolean } = {}
|
||||
): Promise<CycleCloseResult> {
|
||||
const base: CycleCloseResult = { status: 'skipped', subscriptionId: sub.id }
|
||||
|
||||
if (!sub.periodStart || isFree(sub.plan)) return base
|
||||
const periodStart = sub.periodStart
|
||||
|
||||
if (sub.lastClosedPeriodStart && sub.lastClosedPeriodStart.getTime() >= periodStart.getTime()) {
|
||||
return { ...base, status: 'current' }
|
||||
}
|
||||
|
||||
if (!sub.lastClosedPeriodStart) {
|
||||
await db
|
||||
.update(subscriptionTable)
|
||||
.set({ lastClosedPeriodStart: periodStart })
|
||||
.where(and(eq(subscriptionTable.id, sub.id), isNull(subscriptionTable.lastClosedPeriodStart)))
|
||||
logger.info('Initialized cycle-close marker without billing', {
|
||||
subscriptionId: sub.id,
|
||||
plan: sub.plan,
|
||||
periodStart: periodStart.toISOString(),
|
||||
})
|
||||
return { ...base, status: 'initialized' }
|
||||
}
|
||||
|
||||
if (
|
||||
!options.bypassSettlementGrace &&
|
||||
Date.now() - periodStart.getTime() < CLOSE_SETTLEMENT_GRACE_MS
|
||||
) {
|
||||
// Rollover too recent — a run started before it could still insert rows
|
||||
// stamped with the elapsed period. A later sweep closes it with final sums.
|
||||
return base
|
||||
}
|
||||
|
||||
const marker = sub.lastClosedPeriodStart
|
||||
const orgScoped = await isSubscriptionOrgScoped(sub)
|
||||
const billingEntity = orgScoped
|
||||
? ({ type: 'organization', id: sub.referenceId } as const)
|
||||
: ({ type: 'user', id: sub.referenceId } as const)
|
||||
// The elapsed period's exact start, from the ledger's own write-time stamps
|
||||
// (its rows carry `billing_period_end == periodStart` — the renewal
|
||||
// invariant). Deriving the bound from stamps instead of calendar math keeps
|
||||
// the refresh window aligned with the stamped period even when anchor-day
|
||||
// drift (e.g. Jan 31 → Feb 28) makes `periodStart - 1 interval` inexact.
|
||||
const [prevStamp] = await db
|
||||
.select({
|
||||
// mapWith(column) applies the timestamp decoder — a raw aggregate
|
||||
// bypasses column mapping, so the driver would return a string here.
|
||||
start: sql<Date | null>`max(${usageLog.billingPeriodStart})`.mapWith(
|
||||
usageLog.billingPeriodStart
|
||||
),
|
||||
})
|
||||
.from(usageLog)
|
||||
.where(
|
||||
and(
|
||||
eq(usageLog.billingEntityType, billingEntity.type),
|
||||
eq(usageLog.billingEntityId, billingEntity.id),
|
||||
eq(usageLog.billingPeriodEnd, periodStart)
|
||||
)
|
||||
)
|
||||
const expectedPrevStart = prevStamp?.start ?? minusOneInterval(periodStart, sub.billingInterval)
|
||||
// Money and bookkeeping cover exactly one period. A marker further back
|
||||
// than one interval means missed sweeps; those older periods' sub-threshold
|
||||
// tails are forgiven (loudly) rather than billed with multi-period math.
|
||||
const closeFrom = marker.getTime() < expectedPrevStart.getTime() ? expectedPrevStart : marker
|
||||
if (closeFrom.getTime() !== marker.getTime()) {
|
||||
logger.error('Cycle close skipped elapsed periods; forgiving their residual overage', {
|
||||
subscriptionId: sub.id,
|
||||
plan: sub.plan,
|
||||
marker: marker.toISOString(),
|
||||
closingFrom: closeFrom.toISOString(),
|
||||
periodStart: periodStart.toISOString(),
|
||||
})
|
||||
}
|
||||
if (closeFrom.getTime() >= periodStart.getTime()) {
|
||||
// Degenerate window (clock skew or a shortened period) — just advance.
|
||||
const advanced = await db.transaction(async (tx) => claimCloseMarker(tx, sub.id, periodStart))
|
||||
return { ...base, status: advanced ? 'closed' : 'already-closed' }
|
||||
}
|
||||
|
||||
const closedRange = { from: closeFrom, to: periodStart }
|
||||
|
||||
const enterprise = isEnterprise(sub.plan)
|
||||
if (enterprise && usesReportingWindows(sub)) {
|
||||
// Reporting-anchor orgs derive every usage window live from the anchor;
|
||||
// there is nothing to bill or book here. Advance the marker so the sweep
|
||||
// stays quiet.
|
||||
const advanced = await db.transaction(async (tx) => claimCloseMarker(tx, sub.id, periodStart))
|
||||
return { ...base, status: advanced ? 'closed' : 'already-closed' }
|
||||
}
|
||||
|
||||
const [usageByUser, copilotByUser] = await Promise.all([
|
||||
getStampedPeriodRangeUsageCostByUser(billingEntity, closedRange),
|
||||
getStampedPeriodRangeUsageCostByUser(billingEntity, closedRange, COPILOT_USAGE_SOURCES),
|
||||
])
|
||||
let closedLedgerUsage = 0
|
||||
for (const cost of usageByUser.values()) closedLedgerUsage += cost
|
||||
|
||||
const memberRows = orgScoped
|
||||
? await db
|
||||
.select({ userId: member.userId, role: member.role })
|
||||
.from(member)
|
||||
.where(eq(member.organizationId, sub.referenceId))
|
||||
: []
|
||||
const memberIds = orgScoped ? memberRows.map((row) => row.userId) : [sub.referenceId]
|
||||
const trackerUserId = orgScoped
|
||||
? (memberRows.find((row) => row.role === 'owner')?.userId ?? null)
|
||||
: sub.referenceId
|
||||
|
||||
// Final overage for the closed period (enterprise never bills overage).
|
||||
// Refresh reads are scoped by the same entity/period stamps as the ledger
|
||||
// sums, so a departed member's org-attributed rows offset the overage
|
||||
// exactly like a current member's.
|
||||
let totalOverage = 0
|
||||
if (!enterprise) {
|
||||
if (orgScoped) {
|
||||
const { totalOverage: computed } = await computeOrgOverageAmount({
|
||||
plan: sub.plan,
|
||||
seats: sub.seats ?? null,
|
||||
periodStart: closeFrom,
|
||||
periodEnd: periodStart,
|
||||
organizationId: sub.referenceId,
|
||||
pooledLedgerUsage: closedLedgerUsage,
|
||||
})
|
||||
totalOverage = computed
|
||||
} else {
|
||||
const planDollars = getPlanTierDollars(sub.plan)
|
||||
let refreshConsumed = 0
|
||||
if (planDollars > 0) {
|
||||
refreshConsumed = await computeDailyRefreshConsumed({
|
||||
billingEntity,
|
||||
periodStart: closeFrom,
|
||||
periodEnd: periodStart,
|
||||
planDollars,
|
||||
})
|
||||
}
|
||||
const { basePrice } = getPlanPricing(sub.plan)
|
||||
totalOverage = Math.max(0, closedLedgerUsage - refreshConsumed - basePrice)
|
||||
}
|
||||
}
|
||||
|
||||
// Labels use the closed period's END month, matching threshold billing's
|
||||
// period-end labeling for overage invoices.
|
||||
const billingPeriodLabel = periodStart.toISOString().slice(0, 7)
|
||||
const collectMoney = !enterprise && totalOverage > 0
|
||||
if (collectMoney && (!sub.stripeCustomerId || !sub.stripeSubscriptionId)) {
|
||||
// Claiming the marker here would silently forgive the overage. Defer the
|
||||
// whole close — the sweep retries every run until the Stripe linkage is
|
||||
// repaired, and this error is the operator signal.
|
||||
logger.error('Deferring cycle close: overage due but Stripe identifiers are missing', {
|
||||
subscriptionId: sub.id,
|
||||
plan: sub.plan,
|
||||
totalOverage,
|
||||
hasStripeCustomerId: !!sub.stripeCustomerId,
|
||||
hasStripeSubscriptionId: !!sub.stripeSubscriptionId,
|
||||
})
|
||||
return base
|
||||
}
|
||||
if (collectMoney && orgScoped && !trackerUserId) {
|
||||
// Same defer as missing Stripe state: without an owner row there is no
|
||||
// billed-overage tracker or credit target, and claiming the marker would
|
||||
// silently forgive the overage. The sweep retries once ownership is
|
||||
// repaired; mirrors threshold billing's missing-owner handling.
|
||||
logger.error('Deferring cycle close: overage due but organization has no owner', {
|
||||
subscriptionId: sub.id,
|
||||
organizationId: sub.referenceId,
|
||||
plan: sub.plan,
|
||||
totalOverage,
|
||||
})
|
||||
return base
|
||||
}
|
||||
|
||||
const closeResult = await db.transaction(
|
||||
async (
|
||||
tx
|
||||
): Promise<{
|
||||
status: 'closed' | 'already-closed' | 'membership-changed'
|
||||
billed: number
|
||||
creditsApplied: number
|
||||
}> => {
|
||||
await tx.execute(sql.raw(`SET LOCAL lock_timeout = '${BILLING_LOCK_TIMEOUT_MS}ms'`))
|
||||
|
||||
// Canonical lock order: member userStats rows, then the organization row.
|
||||
if (memberIds.length > 0) {
|
||||
await tx
|
||||
.select({ userId: userStats.userId })
|
||||
.from(userStats)
|
||||
.where(inArray(userStats.userId, memberIds))
|
||||
.for('update')
|
||||
}
|
||||
let orgCreditBalance = 0
|
||||
if (orgScoped) {
|
||||
const [orgRow] = await tx
|
||||
.select({ creditBalance: organization.creditBalance })
|
||||
.from(organization)
|
||||
.where(eq(organization.id, sub.referenceId))
|
||||
.for('update')
|
||||
.limit(1)
|
||||
orgCreditBalance = toNumber(toDecimal(orgRow?.creditBalance))
|
||||
}
|
||||
|
||||
// Re-check the marker under the locks: a concurrent closer that already
|
||||
// committed makes this a no-op (its billedOverage reset must not be
|
||||
// mistaken for unbilled overage).
|
||||
const [current] = await tx
|
||||
.select({ lastClosedPeriodStart: subscriptionTable.lastClosedPeriodStart })
|
||||
.from(subscriptionTable)
|
||||
.where(eq(subscriptionTable.id, sub.id))
|
||||
.limit(1)
|
||||
if (
|
||||
current?.lastClosedPeriodStart &&
|
||||
current.lastClosedPeriodStart.getTime() >= periodStart.getTime()
|
||||
) {
|
||||
return { status: 'already-closed', billed: 0, creditsApplied: 0 }
|
||||
}
|
||||
|
||||
// Re-read the roster under the locks, mirroring threshold billing: an
|
||||
// owner transfer moves `billedOverageThisPeriod` between rows, so a
|
||||
// roster read from before the locks could settle against the wrong
|
||||
// tracker or reset a stale member set. The sweep simply retries.
|
||||
if (orgScoped) {
|
||||
const lockedRoster = await tx
|
||||
.select({ userId: member.userId, role: member.role })
|
||||
.from(member)
|
||||
.where(eq(member.organizationId, sub.referenceId))
|
||||
if (rosterSignature(lockedRoster) !== rosterSignature(memberRows)) {
|
||||
return { status: 'membership-changed', billed: 0, creditsApplied: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
let billed = 0
|
||||
let creditsApplied = 0
|
||||
|
||||
if (collectMoney && trackerUserId) {
|
||||
const [tracker] = await tx
|
||||
.select({
|
||||
billedOverageThisPeriod: userStats.billedOverageThisPeriod,
|
||||
creditBalance: userStats.creditBalance,
|
||||
})
|
||||
.from(userStats)
|
||||
.where(eq(userStats.userId, trackerUserId))
|
||||
.limit(1)
|
||||
|
||||
// The tracker's collections belong to the period that began at the
|
||||
// marker — the threshold gate blocks settlement whenever the marker
|
||||
// lags, so nothing newer can be in it. When this close skipped
|
||||
// forgiven periods (`closeFrom` advanced past the marker), those
|
||||
// collections offset a forgiven period's overage, not this one's:
|
||||
// count nothing against this close. The reset below still clears them.
|
||||
const alreadyBilled =
|
||||
closeFrom.getTime() === marker.getTime()
|
||||
? toNumber(toDecimal(tracker?.billedOverageThisPeriod))
|
||||
: 0
|
||||
let remaining = Math.max(0, totalOverage - alreadyBilled)
|
||||
|
||||
if (remaining > 0) {
|
||||
const creditBalance = orgScoped
|
||||
? orgCreditBalance
|
||||
: toNumber(toDecimal(tracker?.creditBalance))
|
||||
if (creditBalance > 0) {
|
||||
creditsApplied = Math.min(creditBalance, remaining)
|
||||
if (orgScoped) {
|
||||
await tx
|
||||
.update(organization)
|
||||
.set({
|
||||
creditBalance: sql`GREATEST(0, ${organization.creditBalance} - ${creditsApplied})`,
|
||||
})
|
||||
.where(eq(organization.id, sub.referenceId))
|
||||
} else {
|
||||
await tx
|
||||
.update(userStats)
|
||||
.set({
|
||||
creditBalance: sql`GREATEST(0, ${userStats.creditBalance} - ${creditsApplied})`,
|
||||
})
|
||||
.where(eq(userStats.userId, trackerUserId))
|
||||
}
|
||||
remaining -= creditsApplied
|
||||
}
|
||||
|
||||
if (remaining >= MIN_CLOSE_INVOICE_DOLLARS) {
|
||||
const amountCents = Math.round(remaining * 100)
|
||||
const idemStem = `cycle-close-overage:${sub.id}:${periodStart.toISOString()}`
|
||||
await enqueueOutboxEvent(tx, OUTBOX_EVENT_TYPES.STRIPE_THRESHOLD_OVERAGE_INVOICE, {
|
||||
customerId: sub.stripeCustomerId,
|
||||
stripeSubscriptionId: sub.stripeSubscriptionId,
|
||||
amountCents,
|
||||
description: `Final overage billing – ${billingPeriodLabel}`,
|
||||
itemDescription: `Usage overage ($${remaining.toFixed(2)})`,
|
||||
billingPeriod: billingPeriodLabel,
|
||||
invoiceIdemKeyStem: `${idemStem}:invoice`,
|
||||
itemIdemKeyStem: `${idemStem}:item`,
|
||||
metadata: {
|
||||
type: 'overage_billing',
|
||||
subscriptionId: sub.stripeSubscriptionId ?? '',
|
||||
billingPeriod: billingPeriodLabel,
|
||||
...(orgScoped ? { organizationId: sub.referenceId } : { userId: sub.referenceId }),
|
||||
},
|
||||
})
|
||||
billed = remaining
|
||||
} else if (remaining > 0) {
|
||||
logger.info('Forgiving sub-minimum cycle-close overage', {
|
||||
subscriptionId: sub.id,
|
||||
remaining,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Bookkeeping: previous-period totals from the same stamped ledger sums.
|
||||
if (memberIds.length > 0) {
|
||||
const lastCostCases = sql.join(
|
||||
memberIds.map(
|
||||
(userId) => sql`WHEN ${userId} THEN ${(usageByUser.get(userId) ?? 0).toString()}`
|
||||
),
|
||||
sql` `
|
||||
)
|
||||
const lastCopilotCases = sql.join(
|
||||
memberIds.map(
|
||||
(userId) => sql`WHEN ${userId} THEN ${(copilotByUser.get(userId) ?? 0).toString()}`
|
||||
),
|
||||
sql` `
|
||||
)
|
||||
await tx
|
||||
.update(userStats)
|
||||
.set({
|
||||
lastPeriodCost: sql`CASE ${userStats.userId} ${lastCostCases} ELSE ${userStats.lastPeriodCost} END`,
|
||||
lastPeriodCopilotCost: sql`CASE ${userStats.userId} ${lastCopilotCases} ELSE ${userStats.lastPeriodCopilotCost} END`,
|
||||
billedOverageThisPeriod: '0',
|
||||
})
|
||||
.where(inArray(userStats.userId, memberIds))
|
||||
}
|
||||
if (orgScoped) {
|
||||
await tx
|
||||
.update(organization)
|
||||
.set({ departedMemberUsage: '0' })
|
||||
.where(eq(organization.id, sub.referenceId))
|
||||
}
|
||||
|
||||
const advanced = await claimCloseMarker(tx, sub.id, periodStart)
|
||||
if (!advanced) {
|
||||
throw new Error(
|
||||
`Cycle-close marker for subscription ${sub.id} advanced concurrently; rolling back`
|
||||
)
|
||||
}
|
||||
|
||||
return { status: 'closed', billed, creditsApplied }
|
||||
}
|
||||
)
|
||||
|
||||
if (closeResult.status === 'already-closed') {
|
||||
return { ...base, status: 'already-closed' }
|
||||
}
|
||||
if (closeResult.status === 'membership-changed') {
|
||||
logger.info('Deferring cycle close: organization membership changed mid-close', {
|
||||
subscriptionId: sub.id,
|
||||
organizationId: sub.referenceId,
|
||||
})
|
||||
return base
|
||||
}
|
||||
|
||||
logger.info('Closed billing period', {
|
||||
subscriptionId: sub.id,
|
||||
plan: sub.plan,
|
||||
orgScoped,
|
||||
closedFrom: closeFrom.toISOString(),
|
||||
closedTo: periodStart.toISOString(),
|
||||
closedLedgerUsage,
|
||||
totalOverage,
|
||||
overageBilled: closeResult.billed,
|
||||
creditsApplied: closeResult.creditsApplied,
|
||||
})
|
||||
|
||||
if (closeResult.billed > 0 || closeResult.creditsApplied > 0) {
|
||||
const actorId = trackerUserId ?? sub.referenceId
|
||||
const settledVia = closeResult.billed > 0 ? 'stripe' : 'credits'
|
||||
recordAudit({
|
||||
actorId,
|
||||
action: AuditAction.OVERAGE_BILLED,
|
||||
resourceType: AuditResourceType.BILLING,
|
||||
resourceId: sub.id,
|
||||
description: `Final overage of $${(closeResult.billed + closeResult.creditsApplied).toFixed(2)} settled at cycle close for ${sub.referenceId}`,
|
||||
metadata: {
|
||||
entityType: billingEntity.type,
|
||||
referenceId: sub.referenceId,
|
||||
...(orgScoped ? { organizationId: sub.referenceId } : {}),
|
||||
plan: sub.plan,
|
||||
amount: closeResult.billed + closeResult.creditsApplied,
|
||||
currency: 'usd',
|
||||
creditsApplied: closeResult.creditsApplied,
|
||||
settledVia,
|
||||
billingPeriod: billingPeriodLabel,
|
||||
},
|
||||
})
|
||||
captureServerEvent(actorId, 'overage_billed', {
|
||||
amount: closeResult.billed + closeResult.creditsApplied,
|
||||
currency: 'usd',
|
||||
entity_type: billingEntity.type,
|
||||
reference_id: sub.referenceId,
|
||||
settled_via: settledVia,
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
...base,
|
||||
status: 'closed',
|
||||
overageBilled: closeResult.billed,
|
||||
creditsApplied: closeResult.creditsApplied,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Terminal bookkeeping for a subscription that is ending (deleted/cancelled):
|
||||
* writes `lastPeriodCost` / `lastPeriodCopilotCost` from the final period's
|
||||
* stamped ledger sums, clears the per-period trackers so a future
|
||||
* subscription starts clean. Money is NOT collected here — the deletion
|
||||
* handler claims the terminal period via `claimTerminalPeriod` and bills the
|
||||
* final overage itself before calling this.
|
||||
*
|
||||
* Reporting-anchor enterprise subscriptions are skipped: their usage windows
|
||||
* derive live from the anchor, and the subscription's Stripe bounds would
|
||||
* range the wrong stamped rows.
|
||||
*/
|
||||
export async function writeFinalPeriodBookkeeping(sub: {
|
||||
id: string
|
||||
plan: string | null
|
||||
referenceId: string
|
||||
billingInterval?: string | null
|
||||
periodStart?: Date | null
|
||||
periodEnd?: Date | null
|
||||
metadata?: unknown
|
||||
}): Promise<void> {
|
||||
if (!sub.periodStart) return
|
||||
const periodStart = sub.periodStart
|
||||
|
||||
if (usesReportingWindows(sub)) return
|
||||
|
||||
const orgScoped = await isSubscriptionOrgScoped(sub)
|
||||
const billingEntity = orgScoped
|
||||
? ({ type: 'organization', id: sub.referenceId } as const)
|
||||
: ({ type: 'user', id: sub.referenceId } as const)
|
||||
const range = { from: periodStart, to: sub.periodEnd ?? new Date() }
|
||||
|
||||
const [usageByUser, copilotByUser] = await Promise.all([
|
||||
getStampedPeriodRangeUsageCostByUser(billingEntity, range),
|
||||
getStampedPeriodRangeUsageCostByUser(billingEntity, range, COPILOT_USAGE_SOURCES),
|
||||
])
|
||||
|
||||
const memberIds = orgScoped
|
||||
? (
|
||||
await db
|
||||
.select({ userId: member.userId })
|
||||
.from(member)
|
||||
.where(eq(member.organizationId, sub.referenceId))
|
||||
).map((row) => row.userId)
|
||||
: [sub.referenceId]
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await tx.execute(sql.raw(`SET LOCAL lock_timeout = '${BILLING_LOCK_TIMEOUT_MS}ms'`))
|
||||
if (memberIds.length > 0) {
|
||||
const lastCostCases = sql.join(
|
||||
memberIds.map(
|
||||
(userId) => sql`WHEN ${userId} THEN ${(usageByUser.get(userId) ?? 0).toString()}`
|
||||
),
|
||||
sql` `
|
||||
)
|
||||
const lastCopilotCases = sql.join(
|
||||
memberIds.map(
|
||||
(userId) => sql`WHEN ${userId} THEN ${(copilotByUser.get(userId) ?? 0).toString()}`
|
||||
),
|
||||
sql` `
|
||||
)
|
||||
await tx
|
||||
.update(userStats)
|
||||
.set({
|
||||
lastPeriodCost: sql`CASE ${userStats.userId} ${lastCostCases} ELSE ${userStats.lastPeriodCost} END`,
|
||||
lastPeriodCopilotCost: sql`CASE ${userStats.userId} ${lastCopilotCases} ELSE ${userStats.lastPeriodCopilotCost} END`,
|
||||
billedOverageThisPeriod: '0',
|
||||
})
|
||||
.where(inArray(userStats.userId, memberIds))
|
||||
}
|
||||
if (orgScoped) {
|
||||
await tx
|
||||
.update(organization)
|
||||
.set({ departedMemberUsage: '0' })
|
||||
.where(eq(organization.id, sub.referenceId))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export interface CycleCloseSweepSummary {
|
||||
candidates: number
|
||||
closed: number
|
||||
initialized: number
|
||||
failed: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Daily catch-all that closes every elapsed billing period. Candidates are
|
||||
* entitled subscriptions whose close marker lags their current `periodStart`
|
||||
* — i.e. the period advanced (via Stripe sync) since the last close. Each
|
||||
* close is independently atomic, so one failure never blocks the rest.
|
||||
*/
|
||||
export async function sweepBillingCycleCloses(): Promise<CycleCloseSweepSummary> {
|
||||
const candidates = await db
|
||||
.select()
|
||||
.from(subscriptionTable)
|
||||
.where(
|
||||
and(
|
||||
inArray(subscriptionTable.status, ENTITLED_SUBSCRIPTION_STATUSES),
|
||||
sql`${subscriptionTable.periodStart} IS NOT NULL`,
|
||||
or(
|
||||
isNull(subscriptionTable.lastClosedPeriodStart),
|
||||
lt(subscriptionTable.lastClosedPeriodStart, subscriptionTable.periodStart)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
const summary: CycleCloseSweepSummary = {
|
||||
candidates: candidates.length,
|
||||
closed: 0,
|
||||
initialized: 0,
|
||||
failed: 0,
|
||||
}
|
||||
|
||||
for (const sub of candidates) {
|
||||
try {
|
||||
const result = await closeElapsedBillingPeriod(sub)
|
||||
if (result.status === 'closed') summary.closed++
|
||||
if (result.status === 'initialized') summary.initialized++
|
||||
} catch (error) {
|
||||
summary.failed++
|
||||
logger.error('Cycle close failed for subscription', {
|
||||
subscriptionId: sub.id,
|
||||
plan: sub.plan,
|
||||
error: getErrorMessage(error),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('Billing cycle-close sweep finished', { ...summary })
|
||||
return summary
|
||||
}
|
||||
@@ -111,17 +111,21 @@ describe('paid-org join billing lock ordering', () => {
|
||||
mockChangeWorkspaceStoragePayersInTx.mockReset()
|
||||
})
|
||||
|
||||
it('locks the personal subscription before mutating userStats', async () => {
|
||||
it('locks the personal subscription before pausing it and never mutates userStats', async () => {
|
||||
const { tx, ops } = createRecordingTx()
|
||||
|
||||
await reapplyPaidOrgJoinBillingForExistingMemberTx(tx as DbOrTx, 'user-1', 'org-1')
|
||||
|
||||
const firstUserStatsUpdate = ops.findIndex((o) => o.op === 'update' && o.table === userStats)
|
||||
const userStatsUpdate = ops.findIndex((o) => o.op === 'update' && o.table === userStats)
|
||||
const subscriptionLock = ops.findIndex((o) => o.op === 'lock' && o.table === subscriptionTable)
|
||||
const subscriptionUpdate = ops.findIndex(
|
||||
(o) => o.op === 'update' && o.table === subscriptionTable
|
||||
)
|
||||
|
||||
expect(firstUserStatsUpdate).toBeGreaterThanOrEqual(0)
|
||||
// Ledger entity stamps attribute usage; join billing no longer touches userStats.
|
||||
expect(userStatsUpdate).toBe(-1)
|
||||
expect(subscriptionLock).toBeGreaterThanOrEqual(0)
|
||||
expect(subscriptionLock).toBeLessThan(firstUserStatsUpdate)
|
||||
expect(subscriptionUpdate).toBeGreaterThan(subscriptionLock)
|
||||
})
|
||||
|
||||
it('still locks an already-paused personal Pro so a concurrent restore cannot pass it', async () => {
|
||||
@@ -276,8 +280,9 @@ describe('workspace payer-change transaction lock ordering', () => {
|
||||
)
|
||||
const payerTransfer = ops.findIndex((entry) => entry.op === 'payer-transfer')
|
||||
expect(workspaceLock).toBeGreaterThanOrEqual(0)
|
||||
expect(userStatsUpdate).toBeGreaterThan(workspaceLock)
|
||||
expect(payerTransfer).toBeGreaterThan(userStatsUpdate)
|
||||
// Ledger entity stamps attribute usage; join billing no longer touches userStats.
|
||||
expect(userStatsUpdate).toBe(-1)
|
||||
expect(payerTransfer).toBeGreaterThan(workspaceLock)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -206,20 +206,14 @@ export interface RestoreProResult {
|
||||
|
||||
/**
|
||||
* Restore a user's personal Pro subscription if it was paused
|
||||
* (`cancelAtPeriodEnd = true`) and merge any snapshotted Pro usage back
|
||||
* into their current-period usage.
|
||||
* (`cancelAtPeriodEnd = true`). No usage moves — ledger entity stamps kept
|
||||
* their personal usage attributed to them throughout the org membership.
|
||||
*
|
||||
* All DB mutations run inside a single transaction so partial progress
|
||||
* cannot be committed: either both the subscription un-pause and the
|
||||
* usage snapshot merge succeed, or neither does. Errors propagate to
|
||||
* the caller so webhook handlers can rely on Stripe retry semantics.
|
||||
* Errors propagate to the caller so webhook handlers can rely on Stripe
|
||||
* retry semantics.
|
||||
*
|
||||
* Idempotent:
|
||||
* - Early returns when the user has no paused Pro subscription, so
|
||||
* re-runs after a successful restore are no-ops.
|
||||
* - The snapshot merge only runs when `proPeriodCostSnapshot > 0`,
|
||||
* so a second call after a prior success (which zeroes the
|
||||
* snapshot) does nothing.
|
||||
* Idempotent: early returns when the user has no paused Pro subscription,
|
||||
* so re-runs after a successful restore are no-ops.
|
||||
*
|
||||
* Called when:
|
||||
* - A member leaves a team (via `removeUserFromOrganization`).
|
||||
@@ -287,46 +281,6 @@ export async function restoreUserProSubscription(userId: string): Promise<Restor
|
||||
})
|
||||
|
||||
result.restored = true
|
||||
|
||||
const [stats] = await tx
|
||||
.select({
|
||||
currentPeriodCost: userStats.currentPeriodCost,
|
||||
proPeriodCostSnapshot: userStats.proPeriodCostSnapshot,
|
||||
})
|
||||
.from(userStats)
|
||||
.where(eq(userStats.userId, userId))
|
||||
.limit(1)
|
||||
|
||||
if (!stats) {
|
||||
return
|
||||
}
|
||||
|
||||
const currentNum = toNumber(toDecimal(stats.currentPeriodCost))
|
||||
const snapshotNum = toNumber(toDecimal(stats.proPeriodCostSnapshot))
|
||||
|
||||
if (snapshotNum <= 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const restoredUsage = (currentNum + snapshotNum).toString()
|
||||
|
||||
await tx
|
||||
.update(userStats)
|
||||
.set({
|
||||
currentPeriodCost: restoredUsage,
|
||||
proPeriodCostSnapshot: '0',
|
||||
proPeriodCostSnapshotAt: null,
|
||||
})
|
||||
.where(eq(userStats.userId, userId))
|
||||
|
||||
result.usageRestored = true
|
||||
|
||||
logger.info('Restored Pro usage snapshot', {
|
||||
userId,
|
||||
previousUsage: currentNum,
|
||||
snapshotUsage: snapshotNum,
|
||||
restoredUsage,
|
||||
})
|
||||
})
|
||||
|
||||
if (result.restored) {
|
||||
@@ -794,10 +748,10 @@ interface PaidOrgJoinBillingActions {
|
||||
|
||||
/**
|
||||
* Applies the billing side-effects of a user joining a paid (Team/Enterprise)
|
||||
* organization inside an existing transaction:
|
||||
* - snapshots current Pro usage so new usage attributes to the org;
|
||||
* - marks personal Pro subscription `cancelAtPeriodEnd=true` and enqueues
|
||||
* the Stripe sync via the outbox;
|
||||
* organization inside an existing transaction: marks the personal Pro
|
||||
* subscription `cancelAtPeriodEnd=true` and enqueues the Stripe sync via the
|
||||
* outbox. No usage is moved — ledger entity stamps already attribute
|
||||
* post-join usage to the organization and pre-join usage to the user.
|
||||
*
|
||||
* Storage follows each workspace's routed payer independently. The workspace
|
||||
* payer-change transaction transfers that workspace's durable byte ledger; a
|
||||
@@ -830,34 +784,6 @@ async function applyPaidOrgJoinBillingTx(
|
||||
.limit(1)
|
||||
|
||||
if (personalPro && !personalPro.cancelAtPeriodEnd) {
|
||||
const [userStatsRow] = await tx
|
||||
.select({ currentPeriodCost: userStats.currentPeriodCost })
|
||||
.from(userStats)
|
||||
.where(eq(userStats.userId, userId))
|
||||
.limit(1)
|
||||
|
||||
if (userStatsRow) {
|
||||
const currentProUsage = userStatsRow.currentPeriodCost || '0'
|
||||
|
||||
await tx
|
||||
.update(userStats)
|
||||
.set({
|
||||
proPeriodCostSnapshot: currentProUsage,
|
||||
proPeriodCostSnapshotAt: new Date(),
|
||||
currentPeriodCost: '0',
|
||||
currentPeriodCopilotCost: '0',
|
||||
})
|
||||
.where(eq(userStats.userId, userId))
|
||||
|
||||
actions.proUsageSnapshotted = true
|
||||
|
||||
logger.info('Snapshotted Pro usage when joining paid org', {
|
||||
userId,
|
||||
proUsageSnapshot: currentProUsage,
|
||||
organizationId,
|
||||
})
|
||||
}
|
||||
|
||||
await tx
|
||||
.update(subscriptionTable)
|
||||
.set({ cancelAtPeriodEnd: true })
|
||||
@@ -1234,26 +1160,6 @@ export async function transferUserBetweenOrganizations(
|
||||
await removeWorkspaceSkillMembershipsTx(tx, workspaceIds, params.userId)
|
||||
}
|
||||
|
||||
const [stats] = await tx
|
||||
.select({ currentPeriodCost: userStats.currentPeriodCost })
|
||||
.from(userStats)
|
||||
.where(eq(userStats.userId, params.userId))
|
||||
.for('update')
|
||||
.limit(1)
|
||||
const usageCaptured = toNumber(toDecimal(stats?.currentPeriodCost))
|
||||
if (usageCaptured > 0) {
|
||||
await tx
|
||||
.update(organization)
|
||||
.set({
|
||||
departedMemberUsage: sql`${organization.departedMemberUsage} + ${usageCaptured}`,
|
||||
})
|
||||
.where(eq(organization.id, params.sourceOrganizationId))
|
||||
await tx
|
||||
.update(userStats)
|
||||
.set({ currentPeriodCost: '0' })
|
||||
.where(eq(userStats.userId, params.userId))
|
||||
}
|
||||
|
||||
const added = await ensureUserInOrganizationTx(tx, {
|
||||
userId: params.userId,
|
||||
organizationId: params.destinationOrganizationId,
|
||||
@@ -1279,7 +1185,9 @@ export async function transferUserBetweenOrganizations(
|
||||
workspaceAccessRevoked,
|
||||
credentialMembershipsRevoked,
|
||||
pendingInvitationsCancelled: cancelledInvitations.length,
|
||||
usageCaptured,
|
||||
// Nothing to capture: the member's ledger rows stay stamped to the
|
||||
// source org's period and are billed at its cycle close.
|
||||
usageCaptured: 0,
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -1299,10 +1207,11 @@ export async function transferUserBetweenOrganizations(
|
||||
*
|
||||
* Handles:
|
||||
* - Owner removal prevention
|
||||
* - Departed member usage capture
|
||||
* - Member record deletion
|
||||
* - Pro subscription restoration when leaving a paid team
|
||||
* - Pro usage restoration from snapshot
|
||||
*
|
||||
* No usage moves on departure: the member's ledger rows stay stamped to the
|
||||
* org's billing period and are billed at its cycle close.
|
||||
*
|
||||
* Note: Users can only belong to one organization at a time.
|
||||
*/
|
||||
@@ -1397,34 +1306,6 @@ export async function removeUserFromOrganization(
|
||||
.returning({ id: invitation.id })
|
||||
: []
|
||||
|
||||
const captureDepartedUsage = async () => {
|
||||
if (skipBillingLogic) return 0
|
||||
|
||||
const [departingUserStats] = await tx
|
||||
.select({ currentPeriodCost: userStats.currentPeriodCost })
|
||||
.from(userStats)
|
||||
.where(eq(userStats.userId, userId))
|
||||
.for('update')
|
||||
.limit(1)
|
||||
|
||||
const usage = toNumber(toDecimal(departingUserStats?.currentPeriodCost))
|
||||
if (usage <= 0) return 0
|
||||
|
||||
await tx
|
||||
.update(organization)
|
||||
.set({
|
||||
departedMemberUsage: sql`${organization.departedMemberUsage} + ${usage}`,
|
||||
})
|
||||
.where(eq(organization.id, organizationId))
|
||||
|
||||
await tx
|
||||
.update(userStats)
|
||||
.set({ currentPeriodCost: '0' })
|
||||
.where(eq(userStats.userId, userId))
|
||||
|
||||
return usage
|
||||
}
|
||||
|
||||
// Permission groups are organization-scoped, so a departing member's group
|
||||
// membership must be cleared whenever they leave the org — including the
|
||||
// zero-workspace early return below (a group can exist with members but no
|
||||
@@ -1439,12 +1320,12 @@ export async function removeUserFromOrganization(
|
||||
)
|
||||
|
||||
if (workspaceIds.length === 0) {
|
||||
const capturedUsage = await captureDepartedUsage()
|
||||
|
||||
return {
|
||||
skipped: false as const,
|
||||
workspaceIdsToRevoke: [] as string[],
|
||||
usageCaptured: capturedUsage,
|
||||
// Nothing to capture: the member's ledger rows stay stamped to
|
||||
// this org's period and are billed at its cycle close.
|
||||
usageCaptured: 0,
|
||||
credentialMembershipsRevoked: 0,
|
||||
pendingInvitationsCancelled: cancelledInvitations.length,
|
||||
}
|
||||
@@ -1484,12 +1365,11 @@ export async function removeUserFromOrganization(
|
||||
userId
|
||||
)
|
||||
await removeWorkspaceSkillMembershipsTx(tx, workspaceIds, userId)
|
||||
const capturedUsage = await captureDepartedUsage()
|
||||
|
||||
return {
|
||||
skipped: false as const,
|
||||
workspaceIdsToRevoke: deletedPerms.map((row) => row.entityId),
|
||||
usageCaptured: capturedUsage,
|
||||
usageCaptured: 0,
|
||||
credentialMembershipsRevoked,
|
||||
pendingInvitationsCancelled: cancelledInvitations.length,
|
||||
}
|
||||
@@ -1513,14 +1393,6 @@ export async function removeUserFromOrganization(
|
||||
// resolving to this org immediately, not after the membership-cache TTL.
|
||||
invalidateMembershipCache(userId)
|
||||
|
||||
if (result.usageCaptured > 0) {
|
||||
logger.info('Captured departed member usage', {
|
||||
organizationId,
|
||||
userId,
|
||||
usage: result.usageCaptured,
|
||||
})
|
||||
}
|
||||
|
||||
logger.info('Removed member from organization', {
|
||||
organizationId,
|
||||
userId,
|
||||
|
||||
@@ -17,6 +17,7 @@ const {
|
||||
mockIsFree,
|
||||
mockIsOrgScopedSubscription,
|
||||
mockIsOrganizationBillingBlocked,
|
||||
mockIsSubscriptionCycleCloseCurrent,
|
||||
mockRecordAudit,
|
||||
mockCaptureServerEvent,
|
||||
} = vi.hoisted(() => ({
|
||||
@@ -32,6 +33,7 @@ const {
|
||||
mockIsFree: vi.fn(),
|
||||
mockIsOrgScopedSubscription: vi.fn(),
|
||||
mockIsOrganizationBillingBlocked: vi.fn(),
|
||||
mockIsSubscriptionCycleCloseCurrent: vi.fn(),
|
||||
mockRecordAudit: vi.fn(),
|
||||
mockCaptureServerEvent: vi.fn(),
|
||||
}))
|
||||
@@ -61,6 +63,10 @@ vi.mock('@/lib/billing/core/usage-log', () => ({
|
||||
getBillingPeriodUsageCost: mockGetBillingPeriodUsageCost,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/billing/cycle-close', () => ({
|
||||
isSubscriptionCycleCloseCurrent: mockIsSubscriptionCycleCloseCurrent,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/billing/plan-helpers', () => ({
|
||||
isEnterprise: mockIsEnterprise,
|
||||
isFree: mockIsFree,
|
||||
@@ -107,29 +113,14 @@ const expectedBillingPeriod = {
|
||||
end: new Date('2026-06-01T00:00:00.000Z'),
|
||||
}
|
||||
|
||||
const defaultUsageSnapshotRow = {
|
||||
currentPeriodCost: '0',
|
||||
proPeriodCostSnapshot: '0',
|
||||
proPeriodCostSnapshotAt: null as Date | null,
|
||||
lastPeriodCost: '0',
|
||||
}
|
||||
|
||||
/**
|
||||
* Queues the two pre-transaction personal reads: the user_stats usage snapshot
|
||||
* and the subscription's Stripe customer row.
|
||||
*/
|
||||
function queuePersonalReads(
|
||||
snapshot: Record<string, unknown> = defaultUsageSnapshotRow,
|
||||
customerId = 'cus_1'
|
||||
) {
|
||||
queueTableRows(schemaMock.userStats, [snapshot])
|
||||
/** Queues the pre-transaction personal read: the subscription's Stripe customer row. */
|
||||
function queuePersonalReads(customerId = 'cus_1') {
|
||||
queueTableRows(schemaMock.subscription, [{ stripeCustomerId: customerId }])
|
||||
}
|
||||
|
||||
/** Builds the locked in-transaction user_stats row. */
|
||||
function lockedStatsRow(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
...defaultUsageSnapshotRow,
|
||||
billedOverageThisPeriod: '0',
|
||||
creditBalance: '0',
|
||||
...overrides,
|
||||
@@ -144,20 +135,18 @@ function queueLockedStats(row: Record<string, unknown>) {
|
||||
const orgMemberUsageRow = {
|
||||
userId: 'owner-1',
|
||||
role: 'owner',
|
||||
currentPeriodCost: '350',
|
||||
departedMemberUsage: '25',
|
||||
}
|
||||
|
||||
/**
|
||||
* Queues the organization settlement reads in table order: the pre-transaction
|
||||
* member usage join, then the locked owner row, owner stats, organization row,
|
||||
* and locked member usage join inside the transaction.
|
||||
* member join, then the locked owner row, owner stats, organization row, and
|
||||
* locked member join inside the transaction.
|
||||
*/
|
||||
function queueOrgReads({
|
||||
memberUsageRows = [orgMemberUsageRow],
|
||||
lockedOwnerRows = [{ userId: 'owner-1' }],
|
||||
ownerStatsRows = [{ billedOverageThisPeriod: '0' }],
|
||||
organizationRows = [{ creditBalance: '0', departedMemberUsage: '25' }],
|
||||
organizationRows = [{ creditBalance: '0' }],
|
||||
lockedMemberUsageRows = memberUsageRows,
|
||||
}: {
|
||||
memberUsageRows?: unknown[]
|
||||
@@ -174,6 +163,7 @@ function queueOrgReads({
|
||||
}
|
||||
|
||||
const usableOrgSubscription = {
|
||||
id: 'sub-db-team-1',
|
||||
plan: 'team',
|
||||
seats: 2,
|
||||
periodStart: new Date('2026-05-01T00:00:00.000Z'),
|
||||
@@ -195,6 +185,7 @@ describe('checkAndBillOverageThreshold', () => {
|
||||
mockIsEnterprise.mockReturnValue(false)
|
||||
mockIsOrgScopedSubscription.mockReturnValue(false)
|
||||
mockGetBillingPeriodUsageCost.mockResolvedValue(0)
|
||||
mockIsSubscriptionCycleCloseCurrent.mockResolvedValue(true)
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
@@ -202,7 +193,6 @@ describe('checkAndBillOverageThreshold', () => {
|
||||
})
|
||||
|
||||
it('does not lock user_stats when calculated overage is below threshold', async () => {
|
||||
queuePersonalReads()
|
||||
mockCalculateSubscriptionOverage.mockResolvedValue(99)
|
||||
|
||||
await checkAndBillOverageThreshold('user-1')
|
||||
@@ -216,7 +206,7 @@ describe('checkAndBillOverageThreshold', () => {
|
||||
periodEnd: userSubscription.periodEnd,
|
||||
})
|
||||
expect(dbChainMockFns.transaction).not.toHaveBeenCalled()
|
||||
expect(dbChainMockFns.select).toHaveBeenCalledTimes(1)
|
||||
expect(dbChainMockFns.select).not.toHaveBeenCalled()
|
||||
expect(mockEnqueueOutboxEvent).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -540,22 +530,12 @@ describe('checkAndBillOverageThreshold', () => {
|
||||
expect(mockEnqueueOutboxEvent).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('skips personal threshold billing when locked usage inputs changed', async () => {
|
||||
queuePersonalReads({ ...defaultUsageSnapshotRow, currentPeriodCost: '250' })
|
||||
queueLockedStats(lockedStatsRow({ lastPeriodCost: '250' }))
|
||||
mockCalculateSubscriptionOverage.mockResolvedValue(250)
|
||||
|
||||
await checkAndBillOverageThreshold('user-1')
|
||||
|
||||
expect(dbChainMockFns.transaction).toHaveBeenCalled()
|
||||
expect(dbChainMockFns.update).not.toHaveBeenCalled()
|
||||
expect(mockEnqueueOutboxEvent).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('throws retryably in markerless strict mode when locked personal usage changes', async () => {
|
||||
queuePersonalReads({ ...defaultUsageSnapshotRow, currentPeriodCost: '250' })
|
||||
queueLockedStats(lockedStatsRow({ lastPeriodCost: '250' }))
|
||||
it('aborts settlement when the period advances between preflight and the locked transaction', async () => {
|
||||
queuePersonalReads()
|
||||
queueLockedStats(lockedStatsRow())
|
||||
mockCalculateSubscriptionOverage.mockResolvedValue(250)
|
||||
// Preflight passes; the under-lock revalidation sees the rollover.
|
||||
mockIsSubscriptionCycleCloseCurrent.mockResolvedValueOnce(true).mockResolvedValueOnce(false)
|
||||
|
||||
await expect(
|
||||
checkAndBillOverageThreshold('user-1', undefined, { onError: 'throw' })
|
||||
@@ -564,10 +544,51 @@ describe('checkAndBillOverageThreshold', () => {
|
||||
code: 'concurrent_state_change',
|
||||
retryable: true,
|
||||
})
|
||||
|
||||
expect(mockIsSubscriptionCycleCloseCurrent).toHaveBeenLastCalledWith(
|
||||
userSubscription.id,
|
||||
expect.objectContaining({ expectedPeriodStart: userSubscription.periodStart })
|
||||
)
|
||||
expect(dbChainMockFns.update).not.toHaveBeenCalled()
|
||||
expect(mockEnqueueOutboxEvent).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('defers personal settlement while the previous period cycle close is pending', async () => {
|
||||
mockIsSubscriptionCycleCloseCurrent.mockResolvedValue(false)
|
||||
mockCalculateSubscriptionOverage.mockResolvedValue(250)
|
||||
|
||||
await expect(
|
||||
checkAndBillOverageThreshold('user-1', undefined, {
|
||||
onError: 'throw',
|
||||
expectedBillingPeriod,
|
||||
})
|
||||
).resolves.toEqual({ status: 'no-op', reason: 'pending-cycle-close' })
|
||||
|
||||
expect(mockIsSubscriptionCycleCloseCurrent).toHaveBeenCalledWith(userSubscription.id)
|
||||
expect(mockCalculateSubscriptionOverage).not.toHaveBeenCalled()
|
||||
expect(dbChainMockFns.transaction).not.toHaveBeenCalled()
|
||||
expect(mockEnqueueOutboxEvent).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('defers organization settlement while the previous period cycle close is pending', async () => {
|
||||
mockIsOrgScopedSubscription.mockReturnValue(true)
|
||||
mockIsOrganizationBillingBlocked.mockResolvedValue(false)
|
||||
mockGetOrganizationSubscriptionUsable.mockResolvedValue(usableOrgSubscription)
|
||||
mockIsSubscriptionCycleCloseCurrent.mockResolvedValue(false)
|
||||
|
||||
await expect(
|
||||
checkAndBillOverageThreshold('user-1', undefined, {
|
||||
onError: 'throw',
|
||||
expectedBillingPeriod,
|
||||
})
|
||||
).resolves.toEqual({ status: 'no-op', reason: 'pending-cycle-close' })
|
||||
|
||||
expect(mockIsSubscriptionCycleCloseCurrent).toHaveBeenCalledWith(usableOrgSubscription.id)
|
||||
expect(mockComputeOrgOverageAmount).not.toHaveBeenCalled()
|
||||
expect(dbChainMockFns.transaction).not.toHaveBeenCalled()
|
||||
expect(mockEnqueueOutboxEvent).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('wraps lock timeouts in markerless strict mode', async () => {
|
||||
queuePersonalReads()
|
||||
mockCalculateSubscriptionOverage.mockResolvedValue(250)
|
||||
@@ -588,6 +609,8 @@ describe('checkAndBillOverageThreshold', () => {
|
||||
mockIsOrgScopedSubscription.mockReturnValue(true)
|
||||
mockIsOrganizationBillingBlocked.mockResolvedValue(false)
|
||||
mockGetOrganizationSubscriptionUsable.mockResolvedValue(usableOrgSubscription)
|
||||
// Pooled entity read — departed members' org-stamped rows are already in.
|
||||
mockGetBillingPeriodUsageCost.mockResolvedValue(350)
|
||||
queueOrgReads()
|
||||
mockComputeOrgOverageAmount.mockResolvedValue({
|
||||
totalOverage: 250,
|
||||
@@ -603,9 +626,7 @@ describe('checkAndBillOverageThreshold', () => {
|
||||
periodStart: new Date('2026-05-01T00:00:00.000Z'),
|
||||
periodEnd: new Date('2026-06-01T00:00:00.000Z'),
|
||||
organizationId: userSubscription.referenceId,
|
||||
pooledCurrentPeriodCost: 350,
|
||||
departedMemberUsage: 25,
|
||||
memberIds: ['owner-1'],
|
||||
pooledLedgerUsage: 350,
|
||||
})
|
||||
expect(dbChainMockFns.transaction).toHaveBeenCalled()
|
||||
expect(mockComputeOrgOverageAmount.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
@@ -615,27 +636,6 @@ describe('checkAndBillOverageThreshold', () => {
|
||||
expect(mockEnqueueOutboxEvent).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('skips stale organization overage when locked usage inputs changed', async () => {
|
||||
mockIsOrgScopedSubscription.mockReturnValue(true)
|
||||
mockIsOrganizationBillingBlocked.mockResolvedValue(false)
|
||||
mockGetOrganizationSubscriptionUsable.mockResolvedValue(usableOrgSubscription)
|
||||
queueOrgReads({
|
||||
organizationRows: [{ creditBalance: '0', departedMemberUsage: '75' }],
|
||||
lockedMemberUsageRows: [{ ...orgMemberUsageRow, departedMemberUsage: '75' }],
|
||||
})
|
||||
mockComputeOrgOverageAmount.mockResolvedValue({
|
||||
totalOverage: 250,
|
||||
baseSubscriptionAmount: 100,
|
||||
effectiveUsage: 350,
|
||||
})
|
||||
|
||||
await checkAndBillOverageThreshold('user-1')
|
||||
|
||||
expect(dbChainMockFns.transaction).toHaveBeenCalled()
|
||||
expect(mockEnqueueOutboxEvent).not.toHaveBeenCalled()
|
||||
expect(dbChainMockFns.update).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rechecks organization billed overage on the locked owner tracker', async () => {
|
||||
mockIsOrgScopedSubscription.mockReturnValue(true)
|
||||
mockIsOrganizationBillingBlocked.mockResolvedValue(false)
|
||||
@@ -659,29 +659,11 @@ describe('checkAndBillOverageThreshold', () => {
|
||||
mockIsOrganizationBillingBlocked.mockResolvedValue(false)
|
||||
mockGetOrganizationSubscriptionUsable.mockResolvedValue(usableOrgSubscription)
|
||||
queueOrgReads({
|
||||
memberUsageRows: [
|
||||
orgMemberUsageRow,
|
||||
{
|
||||
userId: 'member-1',
|
||||
role: 'member',
|
||||
currentPeriodCost: '25',
|
||||
departedMemberUsage: '25',
|
||||
},
|
||||
],
|
||||
memberUsageRows: [orgMemberUsageRow, { userId: 'member-1', role: 'member' }],
|
||||
lockedOwnerRows: [{ userId: 'member-1' }],
|
||||
lockedMemberUsageRows: [
|
||||
{
|
||||
userId: 'owner-1',
|
||||
role: 'member',
|
||||
currentPeriodCost: '350',
|
||||
departedMemberUsage: '25',
|
||||
},
|
||||
{
|
||||
userId: 'member-1',
|
||||
role: 'owner',
|
||||
currentPeriodCost: '25',
|
||||
departedMemberUsage: '25',
|
||||
},
|
||||
{ userId: 'owner-1', role: 'member' },
|
||||
{ userId: 'member-1', role: 'owner' },
|
||||
],
|
||||
})
|
||||
mockComputeOrgOverageAmount.mockResolvedValue({
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
getOrganizationSubscriptionUsable,
|
||||
} from '@/lib/billing/core/subscription'
|
||||
import { type BillingEntity, getBillingPeriodUsageCost } from '@/lib/billing/core/usage-log'
|
||||
import { isSubscriptionCycleCloseCurrent } from '@/lib/billing/cycle-close'
|
||||
import { isEnterprise, isFree } from '@/lib/billing/plan-helpers'
|
||||
import {
|
||||
hasUsableSubscriptionAccess,
|
||||
@@ -30,19 +31,9 @@ const logger = createLogger('ThresholdBilling')
|
||||
const OVERAGE_THRESHOLD = envNumber(env.OVERAGE_THRESHOLD_DOLLARS, DEFAULT_OVERAGE_THRESHOLD)
|
||||
const USAGE_TOTAL_EPSILON = 0.000001
|
||||
|
||||
interface PersonalUsageSnapshot {
|
||||
currentPeriodCost: number
|
||||
proPeriodCostSnapshot: number
|
||||
proPeriodCostSnapshotAt: Date | null
|
||||
lastPeriodCost: number
|
||||
}
|
||||
|
||||
interface OrganizationUsageSnapshot {
|
||||
memberIds: string[]
|
||||
ownerId: string
|
||||
memberSignature: string
|
||||
pooledCurrentPeriodCost: number
|
||||
departedMemberUsage: number
|
||||
}
|
||||
|
||||
interface ThresholdBillingPeriod {
|
||||
@@ -62,6 +53,7 @@ export type ThresholdSettlementNoOpReason =
|
||||
| 'billing-blocked'
|
||||
| 'billing-ineligible'
|
||||
| 'no-subscription'
|
||||
| 'pending-cycle-close'
|
||||
| 'plan-ineligible'
|
||||
|
||||
export type ThresholdSettlementOutcome =
|
||||
@@ -288,10 +280,13 @@ export async function checkAndBillOverageThreshold(
|
||||
return checkAndBillOrganizationOverageThreshold(userSubscription.referenceId, options)
|
||||
}
|
||||
|
||||
const usageSnapshot = await getPersonalUsageSnapshot(userId)
|
||||
if (!usageSnapshot) {
|
||||
logger.warn('User stats not found for threshold billing', { userId })
|
||||
return requireSettlementStateOutcome(options, 'User stats are required for settlement')
|
||||
// Defer settlement while the previous period's cycle close is pending so
|
||||
// `billedOverageThisPeriod` never mixes periods (see
|
||||
// `isSubscriptionCycleCloseCurrent`). The sweep closes it within hours and
|
||||
// a later threshold attempt settles normally.
|
||||
if (!(await isSubscriptionCycleCloseCurrent(userSubscription.id))) {
|
||||
logger.debug('Previous period cycle close pending; deferring threshold billing', { userId })
|
||||
return noOp(options, 'pending-cycle-close')
|
||||
}
|
||||
|
||||
const currentOverage = await calculateSubscriptionOverage({
|
||||
@@ -358,20 +353,27 @@ export async function checkAndBillOverageThreshold(
|
||||
return requireSettlementState(options, 'User stats are required for settlement')
|
||||
}
|
||||
|
||||
const stats = statsRecords[0]
|
||||
const lockedUsageSnapshot = personalUsageSnapshotFromStats(stats)
|
||||
if (!personalUsageSnapshotMatches(usageSnapshot, lockedUsageSnapshot)) {
|
||||
logger.debug('Personal usage changed during threshold billing check; retry later', {
|
||||
// Revalidate the preflight gate under the tracker lock: a rollover and
|
||||
// its cycle close (which resets `billedOverageThisPeriod`) can commit
|
||||
// between the unlocked check and this transaction, and a settlement
|
||||
// computed from the elapsed period must not land on the new period's
|
||||
// tracker.
|
||||
if (
|
||||
!(await isSubscriptionCycleCloseCurrent(userSubscription.id, {
|
||||
executor: tx,
|
||||
expectedPeriodStart: userSubscription.periodStart,
|
||||
}))
|
||||
) {
|
||||
logger.debug('Subscription period advanced during threshold settlement; retry later', {
|
||||
userId,
|
||||
usageSnapshot,
|
||||
lockedUsageSnapshot,
|
||||
})
|
||||
return retryConcurrentSettlement(
|
||||
options,
|
||||
'Personal usage changed during threshold settlement'
|
||||
'Subscription period advanced during threshold settlement'
|
||||
)
|
||||
}
|
||||
|
||||
const stats = statsRecords[0]
|
||||
const billedOverageThisPeriod = toNumber(toDecimal(stats.billedOverageThisPeriod))
|
||||
const unbilledOverage = Math.max(0, currentOverage - billedOverageThisPeriod)
|
||||
|
||||
@@ -558,6 +560,17 @@ async function checkAndBillOrganizationOverageThreshold(
|
||||
return noOp(options, 'billing-blocked')
|
||||
}
|
||||
|
||||
// Defer settlement while the previous period's cycle close is pending so
|
||||
// `billedOverageThisPeriod` never mixes periods (see
|
||||
// `isSubscriptionCycleCloseCurrent`). The sweep closes it within hours and
|
||||
// a later threshold attempt settles normally.
|
||||
if (!(await isSubscriptionCycleCloseCurrent(orgSubscription.id))) {
|
||||
logger.debug('Previous period cycle close pending; deferring org threshold billing', {
|
||||
organizationId,
|
||||
})
|
||||
return noOp(options, 'pending-cycle-close')
|
||||
}
|
||||
|
||||
logger.debug('Found organization subscription', {
|
||||
organizationId,
|
||||
plan: orgSubscription.plan,
|
||||
@@ -569,12 +582,8 @@ async function checkAndBillOrganizationOverageThreshold(
|
||||
.select({
|
||||
userId: member.userId,
|
||||
role: member.role,
|
||||
currentPeriodCost: userStats.currentPeriodCost,
|
||||
departedMemberUsage: organization.departedMemberUsage,
|
||||
})
|
||||
.from(member)
|
||||
.leftJoin(userStats, eq(member.userId, userStats.userId))
|
||||
.innerJoin(organization, eq(organization.id, member.organizationId))
|
||||
.where(eq(member.organizationId, organizationId))
|
||||
|
||||
logger.debug('Found organization members', {
|
||||
@@ -623,16 +632,12 @@ async function checkAndBillOrganizationOverageThreshold(
|
||||
periodStart: orgSubscription.periodStart ?? null,
|
||||
periodEnd: orgSubscription.periodEnd ?? null,
|
||||
organizationId,
|
||||
pooledCurrentPeriodCost: usageSnapshot.pooledCurrentPeriodCost + ledgerUsage,
|
||||
departedMemberUsage: usageSnapshot.departedMemberUsage,
|
||||
memberIds: usageSnapshot.memberIds,
|
||||
pooledLedgerUsage: ledgerUsage,
|
||||
})
|
||||
|
||||
if (currentOverage < threshold) {
|
||||
logger.debug('Organization threshold billing check below threshold before locking', {
|
||||
organizationId,
|
||||
totalTeamUsage:
|
||||
usageSnapshot.pooledCurrentPeriodCost + ledgerUsage + usageSnapshot.departedMemberUsage,
|
||||
ledgerUsage,
|
||||
effectiveTeamUsage,
|
||||
basePrice,
|
||||
@@ -702,6 +707,22 @@ async function checkAndBillOrganizationOverageThreshold(
|
||||
return requireSettlementState(options, 'Owner stats are required for settlement')
|
||||
}
|
||||
|
||||
// Same under-lock revalidation as the personal path (see above).
|
||||
if (
|
||||
!(await isSubscriptionCycleCloseCurrent(orgSubscription.id, {
|
||||
executor: tx,
|
||||
expectedPeriodStart: orgSubscription.periodStart,
|
||||
}))
|
||||
) {
|
||||
logger.debug('Organization period advanced during threshold settlement; retry later', {
|
||||
organizationId,
|
||||
})
|
||||
return retryConcurrentSettlement(
|
||||
options,
|
||||
'Organization period advanced during threshold settlement'
|
||||
)
|
||||
}
|
||||
|
||||
const orgLock = await tx
|
||||
.select()
|
||||
.from(organization)
|
||||
@@ -718,12 +739,8 @@ async function checkAndBillOrganizationOverageThreshold(
|
||||
.select({
|
||||
userId: member.userId,
|
||||
role: member.role,
|
||||
currentPeriodCost: userStats.currentPeriodCost,
|
||||
departedMemberUsage: organization.departedMemberUsage,
|
||||
})
|
||||
.from(member)
|
||||
.leftJoin(userStats, eq(member.userId, userStats.userId))
|
||||
.innerJoin(organization, eq(organization.id, member.organizationId))
|
||||
.where(eq(member.organizationId, organizationId))
|
||||
|
||||
const lockedUsageSnapshot = buildOrganizationUsageSnapshot(lockedMemberUsageRows)
|
||||
@@ -732,15 +749,18 @@ async function checkAndBillOrganizationOverageThreshold(
|
||||
lockedOwnerId !== usageSnapshot.ownerId ||
|
||||
!organizationUsageSnapshotMatches(usageSnapshot, lockedUsageSnapshot)
|
||||
) {
|
||||
logger.debug('Organization usage changed during threshold billing check; retry later', {
|
||||
organizationId,
|
||||
usageSnapshot,
|
||||
lockedUsageSnapshot,
|
||||
lockedOwnerId,
|
||||
})
|
||||
logger.debug(
|
||||
'Organization membership changed during threshold billing check; retry later',
|
||||
{
|
||||
organizationId,
|
||||
usageSnapshot,
|
||||
lockedUsageSnapshot,
|
||||
lockedOwnerId,
|
||||
}
|
||||
)
|
||||
return retryConcurrentSettlement(
|
||||
options,
|
||||
'Organization usage changed during threshold settlement'
|
||||
'Organization membership changed during threshold settlement'
|
||||
)
|
||||
}
|
||||
|
||||
@@ -751,8 +771,6 @@ async function checkAndBillOrganizationOverageThreshold(
|
||||
|
||||
logger.debug('Organization threshold billing check', {
|
||||
organizationId,
|
||||
totalTeamUsage:
|
||||
usageSnapshot.pooledCurrentPeriodCost + ledgerUsage + usageSnapshot.departedMemberUsage,
|
||||
ledgerUsage,
|
||||
effectiveTeamUsage,
|
||||
basePrice,
|
||||
@@ -911,77 +929,20 @@ async function checkAndBillOrganizationOverageThreshold(
|
||||
}
|
||||
}
|
||||
|
||||
async function getPersonalUsageSnapshot(userId: string): Promise<PersonalUsageSnapshot | null> {
|
||||
const [stats] = await db
|
||||
.select({
|
||||
currentPeriodCost: userStats.currentPeriodCost,
|
||||
proPeriodCostSnapshot: userStats.proPeriodCostSnapshot,
|
||||
proPeriodCostSnapshotAt: userStats.proPeriodCostSnapshotAt,
|
||||
lastPeriodCost: userStats.lastPeriodCost,
|
||||
})
|
||||
.from(userStats)
|
||||
.where(eq(userStats.userId, userId))
|
||||
.limit(1)
|
||||
|
||||
return stats ? personalUsageSnapshotFromStats(stats) : null
|
||||
}
|
||||
|
||||
function personalUsageSnapshotFromStats(stats: {
|
||||
currentPeriodCost: string | number | null
|
||||
proPeriodCostSnapshot: string | number | null
|
||||
proPeriodCostSnapshotAt: Date | null
|
||||
lastPeriodCost: string | number | null
|
||||
}): PersonalUsageSnapshot {
|
||||
return {
|
||||
currentPeriodCost: toNumber(toDecimal(stats.currentPeriodCost)),
|
||||
proPeriodCostSnapshot: toNumber(toDecimal(stats.proPeriodCostSnapshot)),
|
||||
proPeriodCostSnapshotAt: stats.proPeriodCostSnapshotAt,
|
||||
lastPeriodCost: toNumber(toDecimal(stats.lastPeriodCost)),
|
||||
}
|
||||
}
|
||||
|
||||
function personalUsageSnapshotMatches(
|
||||
expected: PersonalUsageSnapshot,
|
||||
actual: PersonalUsageSnapshot
|
||||
): boolean {
|
||||
return (
|
||||
Math.abs(expected.currentPeriodCost - actual.currentPeriodCost) <= USAGE_TOTAL_EPSILON &&
|
||||
Math.abs(expected.proPeriodCostSnapshot - actual.proPeriodCostSnapshot) <=
|
||||
USAGE_TOTAL_EPSILON &&
|
||||
Math.abs(expected.lastPeriodCost - actual.lastPeriodCost) <= USAGE_TOTAL_EPSILON &&
|
||||
nullableDateTime(expected.proPeriodCostSnapshotAt) ===
|
||||
nullableDateTime(actual.proPeriodCostSnapshotAt)
|
||||
)
|
||||
}
|
||||
|
||||
function buildOrganizationUsageSnapshot(
|
||||
rows: {
|
||||
userId: string
|
||||
role: string
|
||||
currentPeriodCost: string | number | null
|
||||
departedMemberUsage: string | number | null
|
||||
}[]
|
||||
): OrganizationUsageSnapshot | null {
|
||||
const owner = rows.find((row) => row.role === 'owner')
|
||||
if (!owner) return null
|
||||
|
||||
const sortedRows = [...rows].sort((a, b) => a.userId.localeCompare(b.userId))
|
||||
let pooledCurrentPeriodCost = 0
|
||||
for (const row of sortedRows) {
|
||||
pooledCurrentPeriodCost += toNumber(toDecimal(row.currentPeriodCost))
|
||||
}
|
||||
|
||||
return {
|
||||
memberIds: sortedRows.map((row) => row.userId),
|
||||
ownerId: owner.userId,
|
||||
memberSignature: sortedRows
|
||||
.map(
|
||||
(row) =>
|
||||
`${row.userId}:${row.role}:${toNumber(toDecimal(row.currentPeriodCost)).toFixed(6)}`
|
||||
)
|
||||
.join('|'),
|
||||
pooledCurrentPeriodCost,
|
||||
departedMemberUsage: toNumber(toDecimal(owner.departedMemberUsage)),
|
||||
memberSignature: sortedRows.map((row) => `${row.userId}:${row.role}`).join('|'),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -989,13 +950,5 @@ function organizationUsageSnapshotMatches(
|
||||
expected: OrganizationUsageSnapshot,
|
||||
actual: OrganizationUsageSnapshot
|
||||
): boolean {
|
||||
return (
|
||||
expected.ownerId === actual.ownerId &&
|
||||
expected.memberSignature === actual.memberSignature &&
|
||||
Math.abs(expected.departedMemberUsage - actual.departedMemberUsage) <= USAGE_TOTAL_EPSILON
|
||||
)
|
||||
}
|
||||
|
||||
function nullableDateTime(value: Date | null): number | null {
|
||||
return value?.getTime() ?? null
|
||||
return expected.ownerId === actual.ownerId && expected.memberSignature === actual.memberSignature
|
||||
}
|
||||
|
||||
@@ -96,7 +96,6 @@ vi.mock('@/lib/messaging/email/validation', () => ({
|
||||
import {
|
||||
handleInvoicePaymentFailed,
|
||||
handleInvoicePaymentSucceeded,
|
||||
resetUsageForSubscription,
|
||||
} from '@/lib/billing/webhooks/invoices'
|
||||
import { sendEmail } from '@/lib/messaging/email/mailer'
|
||||
|
||||
@@ -263,51 +262,4 @@ describe('invoice billing recovery', () => {
|
||||
expect(mockUnblockOrgMembers).toHaveBeenCalledWith('org-1', 'payment_failed')
|
||||
expect(mockBlockOrgMembers).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('locks member userStats before the organization row during usage reset', async () => {
|
||||
queueSelectResponse({ limitResult: [{ userId: 'owner-1' }] }) // owner member row
|
||||
queueSelectResponse({ limitResult: [{ userId: 'owner-1' }] }) // owner userStats
|
||||
queueSelectResponse({ whereResult: [{ userId: 'owner-1' }, { userId: 'member-1' }] }) // member ids
|
||||
queueSelectResponse({ whereResult: [] }) // all-member userStats FOR UPDATE (pre-org lock)
|
||||
queueSelectResponse({ limitResult: [{ id: 'org-1' }] }) // organization
|
||||
queueSelectResponse({
|
||||
whereResult: [
|
||||
{ userId: 'owner-1', current: '125', currentCopilot: '10' },
|
||||
{ userId: 'member-1', current: '75', currentCopilot: '5' },
|
||||
],
|
||||
})
|
||||
queueSelectResponse({ whereResult: [] })
|
||||
queueSelectResponse({ whereResult: [] })
|
||||
|
||||
await resetUsageForSubscription({ plan: 'team', referenceId: 'org-1' })
|
||||
|
||||
expect(dbChainMockFns.transaction).toHaveBeenCalledTimes(1)
|
||||
expect(dbChainMockFns.update).toHaveBeenCalledTimes(2)
|
||||
|
||||
const whereArgs = dbChainMockFns.where.mock.calls.map(
|
||||
(call) => call[0] as { type?: string; column?: string; left?: string }
|
||||
)
|
||||
const allMemberStatsLockIndex = whereArgs.findIndex(
|
||||
(arg) => arg?.type === 'inArray' && arg?.column === 'userStats.userId'
|
||||
)
|
||||
const orgLockIndex = whereArgs.findIndex(
|
||||
(arg) => arg?.type === 'eq' && arg?.left === 'organization.id'
|
||||
)
|
||||
expect(allMemberStatsLockIndex).toBeGreaterThanOrEqual(0)
|
||||
expect(orgLockIndex).toBeGreaterThanOrEqual(0)
|
||||
expect(allMemberStatsLockIndex).toBeLessThan(orgLockIndex)
|
||||
|
||||
const statsReset = dbChainMockFns.set.mock.calls[0][0] as Record<string, unknown>
|
||||
expect(statsReset.currentPeriodCost).not.toBe('0')
|
||||
expect(statsReset.currentPeriodCopilotCost).not.toBe('0')
|
||||
expect(statsReset.lastPeriodCost).toMatchObject({
|
||||
toSQL: expect.any(Function),
|
||||
})
|
||||
expect((statsReset.lastPeriodCost as { toSQL: () => { sql: string } }).toSQL().sql).toContain(
|
||||
'CASE'
|
||||
)
|
||||
expect(
|
||||
(statsReset.currentPeriodCost as { toSQL: () => { sql: string } }).toSQL().sql
|
||||
).toContain('GREATEST')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,33 +1,20 @@
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
|
||||
import { db } from '@sim/db'
|
||||
import {
|
||||
member,
|
||||
organization,
|
||||
subscription as subscriptionTable,
|
||||
user,
|
||||
userStats,
|
||||
} from '@sim/db/schema'
|
||||
import { member, subscription as subscriptionTable, user, userStats } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { isOrgAdminRole } from '@sim/platform-authz/workspace'
|
||||
import { and, eq, inArray, isNull, ne, or, sql } from 'drizzle-orm'
|
||||
import { and, eq, inArray, isNull, ne, or } from 'drizzle-orm'
|
||||
import type Stripe from 'stripe'
|
||||
import {
|
||||
getEmailSubject,
|
||||
renderCreditPurchaseEmail,
|
||||
renderPaymentFailedEmail,
|
||||
} from '@/components/emails'
|
||||
import { BILLING_LOCK_TIMEOUT_MS } from '@/lib/billing/constants'
|
||||
import { calculateSubscriptionOverage, isSubscriptionOrgScoped } from '@/lib/billing/core/billing'
|
||||
import {
|
||||
COPILOT_USAGE_SOURCES,
|
||||
getBillingPeriodUsageCostByUser,
|
||||
} from '@/lib/billing/core/usage-log'
|
||||
import { isSubscriptionOrgScoped } from '@/lib/billing/core/billing'
|
||||
import { addCredits, getCreditBalanceForEntity } from '@/lib/billing/credits/balance'
|
||||
import { setUsageLimitForCredits } from '@/lib/billing/credits/purchase'
|
||||
import { blockOrgMembers, unblockOrgMembers } from '@/lib/billing/organizations/membership'
|
||||
import { isEnterprise } from '@/lib/billing/plan-helpers'
|
||||
import { requireStripeClient } from '@/lib/billing/stripe-client'
|
||||
import { resolveDefaultPaymentMethod } from '@/lib/billing/stripe-payment-method'
|
||||
import { ENTITLED_SUBSCRIPTION_STATUSES } from '@/lib/billing/subscriptions/utils'
|
||||
import { toDecimal, toNumber } from '@/lib/billing/utils/decimal'
|
||||
import { stripeWebhookIdempotency } from '@/lib/billing/webhooks/idempotency'
|
||||
@@ -39,6 +26,17 @@ import { captureServerEvent } from '@/lib/posthog/server'
|
||||
|
||||
const logger = createLogger('StripeInvoiceWebhooks')
|
||||
|
||||
/**
|
||||
* Cycle rollover (usage window advance, final overage collection,
|
||||
* `billedOverageThisPeriod` reset, last-period bookkeeping) is NOT handled
|
||||
* here. Usage windows advance automatically — current usage is the attributed
|
||||
* usage_log ledger for the subscription's current period — and the money +
|
||||
* bookkeeping close runs off period advance in
|
||||
* `@/lib/billing/cycle-close`, independent of invoice payload shape. These
|
||||
* handlers only manage payment lifecycle: block/unblock, notification emails,
|
||||
* credit purchases, and audit.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Resolve the audit actor for a billing event. For org-scoped subscriptions the
|
||||
* actor is the org owner; for personal subscriptions it is the reference (user)
|
||||
@@ -64,26 +62,6 @@ async function resolveBillingActorId(isOrgScoped: boolean, referenceId: string):
|
||||
}
|
||||
}
|
||||
|
||||
function getSubscriptionLinePeriod(
|
||||
invoice: Stripe.Invoice,
|
||||
stripeSubscriptionId: string
|
||||
): { periodStart: Date; periodEnd: Date } | null {
|
||||
const subscriptionLine = invoice.lines?.data?.find(
|
||||
(line) =>
|
||||
line.parent?.type === 'subscription_item_details' &&
|
||||
line.parent.subscription_item_details?.subscription === stripeSubscriptionId
|
||||
)
|
||||
|
||||
if (!subscriptionLine?.period?.start || !subscriptionLine.period.end) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
periodStart: new Date(subscriptionLine.period.start * 1000),
|
||||
periodEnd: new Date(subscriptionLine.period.end * 1000),
|
||||
}
|
||||
}
|
||||
|
||||
const METADATA_SUBSCRIPTION_INVOICE_TYPES = new Set<string>([
|
||||
'overage_billing',
|
||||
'overage_threshold_billing',
|
||||
@@ -438,224 +416,6 @@ export async function getBilledOverageForSubscription(sub: {
|
||||
: 0
|
||||
}
|
||||
|
||||
export async function resetUsageForSubscription(sub: {
|
||||
plan: string | null
|
||||
referenceId: string
|
||||
periodStart?: Date | null
|
||||
periodEnd?: Date | null
|
||||
}) {
|
||||
const billingPeriod =
|
||||
sub.periodStart && sub.periodEnd ? { start: sub.periodStart, end: sub.periodEnd } : null
|
||||
|
||||
if (await isSubscriptionOrgScoped(sub)) {
|
||||
const ledgerUsageByUser = billingPeriod
|
||||
? await getBillingPeriodUsageCostByUser(
|
||||
{ type: 'organization', id: sub.referenceId },
|
||||
billingPeriod
|
||||
)
|
||||
: new Map<string, number>()
|
||||
// Copilot-family ledger per user, so last-period copilot mirrors last-period
|
||||
// cost (baseline + usage_log) instead of capturing the baseline alone.
|
||||
const copilotLedgerByUser = billingPeriod
|
||||
? await getBillingPeriodUsageCostByUser(
|
||||
{ type: 'organization', id: sub.referenceId },
|
||||
billingPeriod,
|
||||
COPILOT_USAGE_SOURCES
|
||||
)
|
||||
: new Map<string, number>()
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await tx.execute(sql.raw(`SET LOCAL lock_timeout = '${BILLING_LOCK_TIMEOUT_MS}ms'`))
|
||||
|
||||
const ownerRows = await tx
|
||||
.select({ userId: member.userId })
|
||||
.from(member)
|
||||
.where(and(eq(member.organizationId, sub.referenceId), eq(member.role, 'owner')))
|
||||
.for('update')
|
||||
.limit(1)
|
||||
|
||||
const ownerId = ownerRows[0]?.userId
|
||||
if (ownerId) {
|
||||
await tx
|
||||
.select({ userId: userStats.userId })
|
||||
.from(userStats)
|
||||
.where(eq(userStats.userId, ownerId))
|
||||
.for('update')
|
||||
.limit(1)
|
||||
}
|
||||
|
||||
const membersRows = await tx
|
||||
.select({ userId: member.userId })
|
||||
.from(member)
|
||||
.where(eq(member.organizationId, sub.referenceId))
|
||||
|
||||
const memberIds = membersRows.map((row) => row.userId)
|
||||
|
||||
// Lock every member's userStats before the organization row so this path
|
||||
// follows the canonical userStats → organization order shared by the
|
||||
// join, remove, threshold-billing, and storage-transfer paths. Locking
|
||||
// organization first would invert against them and risk an AB-BA
|
||||
// deadlock. The per-member UPDATE below re-locks these rows (no-op).
|
||||
if (memberIds.length > 0) {
|
||||
await tx
|
||||
.select({ userId: userStats.userId })
|
||||
.from(userStats)
|
||||
.where(inArray(userStats.userId, memberIds))
|
||||
.for('update')
|
||||
}
|
||||
|
||||
await tx
|
||||
.select({ id: organization.id })
|
||||
.from(organization)
|
||||
.where(eq(organization.id, sub.referenceId))
|
||||
.for('update')
|
||||
.limit(1)
|
||||
if (memberIds.length > 0) {
|
||||
const memberStatsRows = await tx
|
||||
.select({
|
||||
userId: userStats.userId,
|
||||
current: userStats.currentPeriodCost,
|
||||
currentCopilot: userStats.currentPeriodCopilotCost,
|
||||
})
|
||||
.from(userStats)
|
||||
.where(inArray(userStats.userId, memberIds))
|
||||
|
||||
const statsUserIds = memberStatsRows.map((row) => row.userId)
|
||||
if (statsUserIds.length === 0) {
|
||||
await tx
|
||||
.update(organization)
|
||||
.set({ departedMemberUsage: '0' })
|
||||
.where(eq(organization.id, sub.referenceId))
|
||||
return
|
||||
}
|
||||
|
||||
const lastCostByUser = sql.join(
|
||||
memberStatsRows.map((row) => {
|
||||
const baseline = toNumber(toDecimal(row.current))
|
||||
const ledgerUsage = ledgerUsageByUser.get(row.userId) ?? 0
|
||||
const lastPeriodCost = (baseline + ledgerUsage).toString()
|
||||
return sql`WHEN ${row.userId} THEN ${lastPeriodCost}`
|
||||
}),
|
||||
sql` `
|
||||
)
|
||||
const currentCostByUser = sql.join(
|
||||
memberStatsRows.map((row) => sql`WHEN ${row.userId} THEN ${row.current ?? '0'}`),
|
||||
sql` `
|
||||
)
|
||||
const currentCopilotCostByUser = sql.join(
|
||||
memberStatsRows.map((row) => sql`WHEN ${row.userId} THEN ${row.currentCopilot ?? '0'}`),
|
||||
sql` `
|
||||
)
|
||||
// Last-period copilot = baseline copilot + copilot-family ledger, mirroring
|
||||
// lastPeriodCost. (The reset below still subtracts only the baseline, since
|
||||
// the ledger is period-scoped and rolls over on its own.)
|
||||
const lastCopilotCostByUser = sql.join(
|
||||
memberStatsRows.map((row) => {
|
||||
const baselineCopilot = toNumber(toDecimal(row.currentCopilot))
|
||||
const copilotLedger = copilotLedgerByUser.get(row.userId) ?? 0
|
||||
return sql`WHEN ${row.userId} THEN ${(baselineCopilot + copilotLedger).toString()}`
|
||||
}),
|
||||
sql` `
|
||||
)
|
||||
const capturedLastCost = sql`CASE ${userStats.userId} ${lastCostByUser} ELSE '0' END`
|
||||
const capturedCurrentCost = sql`CASE ${userStats.userId} ${currentCostByUser} ELSE '0' END`
|
||||
const capturedCurrentCopilotCost = sql`CASE ${userStats.userId} ${currentCopilotCostByUser} ELSE '0' END`
|
||||
const capturedLastCopilotCost = sql`CASE ${userStats.userId} ${lastCopilotCostByUser} ELSE '0' END`
|
||||
|
||||
await tx
|
||||
.update(userStats)
|
||||
.set({
|
||||
lastPeriodCost: capturedLastCost,
|
||||
lastPeriodCopilotCost: capturedLastCopilotCost,
|
||||
currentPeriodCost: sql`GREATEST(0, ${userStats.currentPeriodCost} - (${capturedCurrentCost})::decimal)`,
|
||||
currentPeriodCopilotCost: sql`GREATEST(0, ${userStats.currentPeriodCopilotCost} - (${capturedCurrentCopilotCost})::decimal)`,
|
||||
billedOverageThisPeriod: '0',
|
||||
})
|
||||
.where(inArray(userStats.userId, statsUserIds))
|
||||
}
|
||||
|
||||
await tx
|
||||
.update(organization)
|
||||
.set({ departedMemberUsage: '0' })
|
||||
.where(eq(organization.id, sub.referenceId))
|
||||
})
|
||||
} else {
|
||||
const currentStats = await db
|
||||
.select({
|
||||
current: userStats.currentPeriodCost,
|
||||
snapshot: userStats.proPeriodCostSnapshot,
|
||||
currentCopilot: userStats.currentPeriodCopilotCost,
|
||||
})
|
||||
.from(userStats)
|
||||
.where(eq(userStats.userId, sub.referenceId))
|
||||
.limit(1)
|
||||
if (currentStats.length > 0) {
|
||||
const current = currentStats[0].current || '0'
|
||||
const snapshot = toNumber(toDecimal(currentStats[0].snapshot))
|
||||
const currentCopilot = currentStats[0].currentCopilot || '0'
|
||||
const ledgerUsage = billingPeriod
|
||||
? await getBillingPeriodUsageCostByUser(
|
||||
{ type: 'user', id: sub.referenceId },
|
||||
billingPeriod
|
||||
)
|
||||
: new Map<string, number>()
|
||||
const userLedgerUsage = ledgerUsage.get(sub.referenceId) ?? 0
|
||||
const copilotLedgerUsage = billingPeriod
|
||||
? ((
|
||||
await getBillingPeriodUsageCostByUser(
|
||||
{ type: 'user', id: sub.referenceId },
|
||||
billingPeriod,
|
||||
COPILOT_USAGE_SOURCES
|
||||
)
|
||||
).get(sub.referenceId) ?? 0)
|
||||
: 0
|
||||
|
||||
// Snapshot > 0: user joined a paid org mid-cycle. The pre-join
|
||||
// portion was billed on this invoice (snapshot); `currentPeriodCost`
|
||||
// is post-join usage the org will bill next cycle-close, so keep
|
||||
// it. Only retire the personal-billing trackers here.
|
||||
if (snapshot > 0) {
|
||||
await db
|
||||
.update(userStats)
|
||||
.set({
|
||||
lastPeriodCost: (snapshot + userLedgerUsage).toString(),
|
||||
// Pre-join personal copilot = the user-scoped copilot ledger only
|
||||
// (post-join copilot usage is org-attributed, so this captures the
|
||||
// pre-join portion). The copilot baseline stays with the org via the
|
||||
// retained currentPeriodCopilotCost, so don't add it here (avoids a
|
||||
// double count at the org's cycle-close).
|
||||
lastPeriodCopilotCost: copilotLedgerUsage.toString(),
|
||||
proPeriodCostSnapshot: '0',
|
||||
proPeriodCostSnapshotAt: null,
|
||||
billedOverageThisPeriod: '0',
|
||||
})
|
||||
.where(eq(userStats.userId, sub.referenceId))
|
||||
} else {
|
||||
const totalLastPeriod = (
|
||||
toNumber(toDecimal(current)) +
|
||||
snapshot +
|
||||
userLedgerUsage
|
||||
).toString()
|
||||
// Delta-reset for the same reason as the org branch above.
|
||||
await db
|
||||
.update(userStats)
|
||||
.set({
|
||||
lastPeriodCost: totalLastPeriod,
|
||||
lastPeriodCopilotCost: (
|
||||
toNumber(toDecimal(currentCopilot)) + copilotLedgerUsage
|
||||
).toString(),
|
||||
currentPeriodCost: sql`GREATEST(0, ${userStats.currentPeriodCost} - ${current}::decimal)`,
|
||||
currentPeriodCopilotCost: sql`GREATEST(0, ${userStats.currentPeriodCopilotCost} - ${currentCopilot}::decimal)`,
|
||||
proPeriodCostSnapshot: '0',
|
||||
proPeriodCostSnapshotAt: null,
|
||||
billedOverageThisPeriod: '0',
|
||||
})
|
||||
.where(eq(userStats.userId, sub.referenceId))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle credit purchase invoice payment succeeded.
|
||||
*/
|
||||
@@ -854,30 +614,6 @@ export async function handleInvoicePaymentSucceeded(event: Stripe.Event) {
|
||||
const { sub } = resolvedInvoice
|
||||
const subIsOrgScoped = await isSubscriptionOrgScoped(sub)
|
||||
|
||||
let wasBlocked = false
|
||||
if (subIsOrgScoped) {
|
||||
const membersRows = await db
|
||||
.select({ userId: member.userId })
|
||||
.from(member)
|
||||
.where(eq(member.organizationId, sub.referenceId))
|
||||
const memberIds = membersRows.map((m) => m.userId)
|
||||
if (memberIds.length > 0) {
|
||||
const blockedRows = await db
|
||||
.select({ blocked: userStats.billingBlocked })
|
||||
.from(userStats)
|
||||
.where(inArray(userStats.userId, memberIds))
|
||||
|
||||
wasBlocked = blockedRows.some((row) => !!row.blocked)
|
||||
}
|
||||
} else {
|
||||
const row = await db
|
||||
.select({ blocked: userStats.billingBlocked })
|
||||
.from(userStats)
|
||||
.where(eq(userStats.userId, sub.referenceId))
|
||||
.limit(1)
|
||||
wasBlocked = row.length > 0 ? !!row[0].blocked : false
|
||||
}
|
||||
|
||||
const isProrationInvoice = invoice.billing_reason === 'subscription_update'
|
||||
const shouldUnblock = !isProrationInvoice || (invoice.amount_paid ?? 0) > 0
|
||||
|
||||
@@ -903,19 +639,6 @@ export async function handleInvoicePaymentSucceeded(event: Stripe.Event) {
|
||||
})
|
||||
}
|
||||
|
||||
if (wasBlocked && !isProrationInvoice) {
|
||||
const invoicePeriod = getSubscriptionLinePeriod(
|
||||
invoice,
|
||||
resolvedInvoice.stripeSubscriptionId
|
||||
)
|
||||
await resetUsageForSubscription({
|
||||
plan: sub.plan,
|
||||
referenceId: sub.referenceId,
|
||||
periodStart: invoicePeriod?.periodStart ?? null,
|
||||
periodEnd: invoicePeriod?.periodEnd ?? null,
|
||||
})
|
||||
}
|
||||
|
||||
const entityType = subIsOrgScoped ? 'organization' : 'user'
|
||||
const amountPaid = (invoice.amount_paid ?? 0) / 100
|
||||
const actorId = await resolveBillingActorId(subIsOrgScoped, sub.referenceId)
|
||||
@@ -1092,268 +815,3 @@ export async function handleInvoicePaymentFailed(event: Stripe.Event) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle base invoice finalized → create a separate overage-only invoice
|
||||
* Note: Enterprise plans no longer have overages
|
||||
*/
|
||||
export async function handleInvoiceFinalized(event: Stripe.Event) {
|
||||
try {
|
||||
const invoice = event.data.object as Stripe.Invoice
|
||||
const subscription = invoice.parent?.subscription_details?.subscription
|
||||
const stripeSubscriptionId = typeof subscription === 'string' ? subscription : subscription?.id
|
||||
if (!stripeSubscriptionId) {
|
||||
logger.info('No subscription found on invoice; skipping finalized handler', {
|
||||
invoiceId: invoice.id,
|
||||
})
|
||||
return
|
||||
}
|
||||
if (invoice.billing_reason && invoice.billing_reason !== 'subscription_cycle') return
|
||||
|
||||
const records = await db
|
||||
.select()
|
||||
.from(subscriptionTable)
|
||||
.where(eq(subscriptionTable.stripeSubscriptionId, stripeSubscriptionId))
|
||||
.limit(1)
|
||||
|
||||
if (records.length === 0) return
|
||||
const sub = records[0]
|
||||
|
||||
const invoicePeriod = getSubscriptionLinePeriod(invoice, stripeSubscriptionId)
|
||||
if (!invoicePeriod) {
|
||||
logger.error('Missing subscription line period on subscription cycle invoice', {
|
||||
invoiceId: invoice.id,
|
||||
stripeSubscriptionId,
|
||||
})
|
||||
if (isEnterprise(sub.plan)) {
|
||||
await resetUsageForSubscription({ plan: sub.plan, referenceId: sub.referenceId })
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (isEnterprise(sub.plan)) {
|
||||
await resetUsageForSubscription({
|
||||
plan: sub.plan,
|
||||
referenceId: sub.referenceId,
|
||||
periodStart: invoicePeriod.periodStart,
|
||||
periodEnd: invoicePeriod.periodEnd,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
await stripeWebhookIdempotency.executeWithIdempotency(
|
||||
'invoice-finalized',
|
||||
event.id,
|
||||
async () => {
|
||||
const stripe = requireStripeClient()
|
||||
const periodStart = Math.floor(invoicePeriod.periodStart.getTime() / 1000)
|
||||
const periodEnd = Math.floor(invoicePeriod.periodEnd.getTime() / 1000)
|
||||
const billingPeriod = new Date(periodEnd * 1000).toISOString().slice(0, 7)
|
||||
|
||||
const totalOverage = await calculateSubscriptionOverage({
|
||||
...sub,
|
||||
periodStart: new Date(periodStart * 1000),
|
||||
periodEnd: new Date(periodEnd * 1000),
|
||||
})
|
||||
|
||||
const entityType = (await isSubscriptionOrgScoped(sub)) ? 'organization' : 'user'
|
||||
const entityId = sub.referenceId
|
||||
|
||||
// Phase 1 — atomic commit. Resolve org owners inside the transaction,
|
||||
// then lock the tracker row so `billedOverageThisPeriod` is serialized
|
||||
// against threshold billing, resets, owner transfers, and retries.
|
||||
const phase1 = await db.transaction(async (tx) => {
|
||||
await tx.execute(sql.raw(`SET LOCAL lock_timeout = '${BILLING_LOCK_TIMEOUT_MS}ms'`))
|
||||
|
||||
let trackerUserId = entityId
|
||||
if (entityType === 'organization') {
|
||||
const ownerRows = await tx
|
||||
.select({ userId: member.userId })
|
||||
.from(member)
|
||||
.where(and(eq(member.organizationId, entityId), eq(member.role, 'owner')))
|
||||
.for('update')
|
||||
.limit(1)
|
||||
const ownerId = ownerRows[0]?.userId
|
||||
if (!ownerId) {
|
||||
throw new Error(
|
||||
`Organization ${entityId} has no owner member; cannot process invoice finalization`
|
||||
)
|
||||
}
|
||||
trackerUserId = ownerId
|
||||
}
|
||||
|
||||
const trackerRows = await tx
|
||||
.select({ billed: userStats.billedOverageThisPeriod })
|
||||
.from(userStats)
|
||||
.where(eq(userStats.userId, trackerUserId))
|
||||
.for('update')
|
||||
.limit(1)
|
||||
|
||||
const billedInTx = trackerRows.length > 0 ? toNumber(toDecimal(trackerRows[0].billed)) : 0
|
||||
const remaining = Math.max(0, totalOverage - billedInTx)
|
||||
|
||||
if (remaining === 0) {
|
||||
return { billedInTx, applied: 0, billed: 0, remaining: 0 }
|
||||
}
|
||||
|
||||
const lockedBalance =
|
||||
entityType === 'organization'
|
||||
? await tx
|
||||
.select({ creditBalance: organization.creditBalance })
|
||||
.from(organization)
|
||||
.where(eq(organization.id, entityId))
|
||||
.for('update')
|
||||
.limit(1)
|
||||
: await tx
|
||||
.select({ creditBalance: userStats.creditBalance })
|
||||
.from(userStats)
|
||||
.where(eq(userStats.userId, entityId))
|
||||
.for('update')
|
||||
.limit(1)
|
||||
|
||||
const creditBalance =
|
||||
lockedBalance.length > 0 ? toNumber(toDecimal(lockedBalance[0].creditBalance)) : 0
|
||||
|
||||
const applied = Math.min(creditBalance, remaining)
|
||||
const billed = remaining - applied
|
||||
|
||||
if (applied > 0) {
|
||||
if (entityType === 'organization') {
|
||||
await tx
|
||||
.update(organization)
|
||||
.set({
|
||||
creditBalance: sql`GREATEST(0, ${organization.creditBalance} - ${applied})`,
|
||||
})
|
||||
.where(eq(organization.id, entityId))
|
||||
} else {
|
||||
await tx
|
||||
.update(userStats)
|
||||
.set({
|
||||
creditBalance: sql`GREATEST(0, ${userStats.creditBalance} - ${applied})`,
|
||||
})
|
||||
.where(eq(userStats.userId, entityId))
|
||||
}
|
||||
}
|
||||
|
||||
await tx
|
||||
.update(userStats)
|
||||
.set({ billedOverageThisPeriod: totalOverage.toString() })
|
||||
.where(eq(userStats.userId, trackerUserId))
|
||||
|
||||
return { billedInTx, applied, billed, remaining }
|
||||
})
|
||||
|
||||
const creditsApplied = phase1.applied
|
||||
const amountToBillStripe = phase1.billed
|
||||
|
||||
logger.info('Invoice finalized overage calculation', {
|
||||
subscriptionId: sub.id,
|
||||
totalOverage,
|
||||
billedOverageBeforeTx: phase1.billedInTx,
|
||||
creditsApplied,
|
||||
amountToBillStripe,
|
||||
billingPeriod,
|
||||
})
|
||||
|
||||
// Phase 2 — Stripe invoice. Runs outside any DB transaction.
|
||||
// Every call uses a deterministic idempotency key so retries
|
||||
// converge on the same invoice object: re-create returns the
|
||||
// existing draft, re-finalize no-ops on an already-finalized
|
||||
// invoice, re-pay no-ops on an already-paid invoice.
|
||||
if (amountToBillStripe > 0) {
|
||||
const customerId = String(invoice.customer)
|
||||
const cents = Math.round(amountToBillStripe * 100)
|
||||
const itemIdemKey = `overage-item:${customerId}:${stripeSubscriptionId}:${billingPeriod}`
|
||||
const invoiceIdemKey = `overage-invoice:${customerId}:${stripeSubscriptionId}:${billingPeriod}`
|
||||
const finalizeIdemKey = `overage-finalize:${customerId}:${stripeSubscriptionId}:${billingPeriod}`
|
||||
const payIdemKey = `overage-pay:${customerId}:${stripeSubscriptionId}:${billingPeriod}`
|
||||
|
||||
const { paymentMethodId: defaultPaymentMethod, collectionMethod } =
|
||||
await resolveDefaultPaymentMethod(stripe, stripeSubscriptionId, customerId)
|
||||
|
||||
const effectiveCollectionMethod = collectionMethod ?? 'charge_automatically'
|
||||
|
||||
const overageInvoice = await stripe.invoices.create(
|
||||
{
|
||||
customer: customerId,
|
||||
collection_method: effectiveCollectionMethod,
|
||||
auto_advance: false,
|
||||
...(defaultPaymentMethod ? { default_payment_method: defaultPaymentMethod } : {}),
|
||||
metadata: {
|
||||
type: 'overage_billing',
|
||||
billingPeriod,
|
||||
subscriptionId: stripeSubscriptionId,
|
||||
},
|
||||
},
|
||||
{ idempotencyKey: invoiceIdemKey }
|
||||
)
|
||||
|
||||
await stripe.invoiceItems.create(
|
||||
{
|
||||
customer: customerId,
|
||||
invoice: overageInvoice.id,
|
||||
amount: cents,
|
||||
currency: 'usd',
|
||||
description: `Usage Based Overage – ${billingPeriod}`,
|
||||
metadata: {
|
||||
type: 'overage_billing',
|
||||
billingPeriod,
|
||||
subscriptionId: stripeSubscriptionId,
|
||||
},
|
||||
},
|
||||
{ idempotencyKey: itemIdemKey }
|
||||
)
|
||||
|
||||
const draftId = overageInvoice.id
|
||||
if (typeof draftId !== 'string' || draftId.length === 0) {
|
||||
logger.error('Stripe created overage invoice without id; aborting finalize')
|
||||
} else {
|
||||
const finalized = await stripe.invoices.finalizeInvoice(
|
||||
draftId,
|
||||
{},
|
||||
{ idempotencyKey: finalizeIdemKey }
|
||||
)
|
||||
if (
|
||||
effectiveCollectionMethod === 'charge_automatically' &&
|
||||
finalized.status === 'open'
|
||||
) {
|
||||
try {
|
||||
const payId = finalized.id
|
||||
if (typeof payId !== 'string' || payId.length === 0) {
|
||||
logger.error('Finalized invoice missing id')
|
||||
throw new Error('Finalized invoice missing id')
|
||||
}
|
||||
await stripe.invoices.pay(
|
||||
payId,
|
||||
{ payment_method: defaultPaymentMethod },
|
||||
{ idempotencyKey: payIdemKey }
|
||||
)
|
||||
} catch (payError) {
|
||||
logger.error('Failed to auto-pay overage invoice', {
|
||||
error: payError,
|
||||
invoiceId: finalized.id,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 3 — reset usage for the new period. Clears trackers and
|
||||
// rolls `currentPeriodCost` forward by delta. Idempotent on its
|
||||
// own (delta subtraction of a value that's already been
|
||||
// subtracted is a no-op).
|
||||
await resetUsageForSubscription({
|
||||
plan: sub.plan,
|
||||
referenceId: sub.referenceId,
|
||||
periodStart: invoicePeriod.periodStart,
|
||||
periodEnd: invoicePeriod.periodEnd,
|
||||
})
|
||||
|
||||
return { totalOverage, creditsApplied, amountToBillStripe }
|
||||
}
|
||||
)
|
||||
} catch (error) {
|
||||
logger.error('Failed to handle invoice finalized', { error })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,15 +5,17 @@ import { createLogger } from '@sim/logger'
|
||||
import { and, eq, inArray, ne } from 'drizzle-orm'
|
||||
import { calculateSubscriptionOverage, isSubscriptionOrgScoped } from '@/lib/billing/core/billing'
|
||||
import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage'
|
||||
import {
|
||||
claimTerminalPeriod,
|
||||
closeElapsedPeriodBeforeDeletion,
|
||||
writeFinalPeriodBookkeeping,
|
||||
} from '@/lib/billing/cycle-close'
|
||||
import { restoreUserProSubscription } from '@/lib/billing/organizations/membership'
|
||||
import { isEnterprise, isPaid, isPro, isTeam } from '@/lib/billing/plan-helpers'
|
||||
import { requireStripeClient } from '@/lib/billing/stripe-client'
|
||||
import { ENTITLED_SUBSCRIPTION_STATUSES } from '@/lib/billing/subscriptions/utils'
|
||||
import { stripeWebhookIdempotency } from '@/lib/billing/webhooks/idempotency'
|
||||
import {
|
||||
getBilledOverageForSubscription,
|
||||
resetUsageForSubscription,
|
||||
} from '@/lib/billing/webhooks/invoices'
|
||||
import { getBilledOverageForSubscription } from '@/lib/billing/webhooks/invoices'
|
||||
import { captureServerEvent } from '@/lib/posthog/server'
|
||||
import { detachOrganizationWorkspaces } from '@/lib/workspaces/organization-workspaces'
|
||||
|
||||
@@ -185,35 +187,17 @@ export async function handleSubscriptionCreated(
|
||||
const wasFreePreviously = otherActiveSubscriptions.length === 0
|
||||
const isPaidPlan = isPaid(subscriptionData.plan)
|
||||
|
||||
if (wasFreePreviously && isPaidPlan) {
|
||||
logger.info('Detected free -> paid transition, resetting usage', {
|
||||
subscriptionId: subscriptionData.id,
|
||||
referenceId: subscriptionData.referenceId,
|
||||
plan: subscriptionData.plan,
|
||||
})
|
||||
|
||||
await resetUsageForSubscription({
|
||||
plan: subscriptionData.plan,
|
||||
referenceId: subscriptionData.referenceId,
|
||||
periodStart: subscriptionData.periodStart ?? null,
|
||||
periodEnd: subscriptionData.periodEnd ?? null,
|
||||
})
|
||||
|
||||
logger.info('Successfully reset usage for free -> paid transition', {
|
||||
subscriptionId: subscriptionData.id,
|
||||
referenceId: subscriptionData.referenceId,
|
||||
plan: subscriptionData.plan,
|
||||
})
|
||||
} else {
|
||||
logger.info('No usage reset needed', {
|
||||
subscriptionId: subscriptionData.id,
|
||||
referenceId: subscriptionData.referenceId,
|
||||
plan: subscriptionData.plan,
|
||||
wasFreePreviously,
|
||||
isPaidPlan,
|
||||
otherActiveSubscriptionsCount: otherActiveSubscriptions.length,
|
||||
})
|
||||
}
|
||||
// No usage reset on free -> paid: usage is the attributed ledger, and
|
||||
// the new subscription's period window starts empty by construction
|
||||
// (rows are stamped with the paid period at write time).
|
||||
logger.info('Processed subscription creation', {
|
||||
subscriptionId: subscriptionData.id,
|
||||
referenceId: subscriptionData.referenceId,
|
||||
plan: subscriptionData.plan,
|
||||
wasFreePreviously,
|
||||
isPaidPlan,
|
||||
otherActiveSubscriptionsCount: otherActiveSubscriptions.length,
|
||||
})
|
||||
|
||||
if (wasFreePreviously && isPaidPlan) {
|
||||
// Best-effort instrumentation; a transient DB error here must never abort
|
||||
@@ -276,8 +260,10 @@ export async function handleSubscriptionDeleted(
|
||||
referenceId: string
|
||||
stripeSubscriptionId: string | null
|
||||
seats?: number | null
|
||||
billingInterval?: string | null
|
||||
periodStart?: Date | null
|
||||
periodEnd?: Date | null
|
||||
metadata?: unknown
|
||||
},
|
||||
stripeEventId?: string
|
||||
) {
|
||||
@@ -299,15 +285,45 @@ export async function handleSubscriptionDeleted(
|
||||
'subscription-deleted',
|
||||
idempotencyIdentifier,
|
||||
async () => {
|
||||
const totalOverage = await calculateSubscriptionOverage(subscription)
|
||||
// Settle any elapsed period the sweep has not closed yet — a deleted
|
||||
// subscription leaves the sweep's candidate set, so this is the last
|
||||
// chance to bill it (and to reset the threshold tracker so the
|
||||
// terminal settlement below is not offset by the elapsed period's
|
||||
// collections).
|
||||
await closeElapsedPeriodBeforeDeletion(subscription.id)
|
||||
|
||||
// Then claim the terminal period BEFORE computing or charging: this
|
||||
// reads the row's fresh period (webhook payloads can be stale across
|
||||
// a rollover) and serializes with the cycle-close sweep. A lagging
|
||||
// marker here means the close above deferred OR a rollover committed
|
||||
// in between — run the close once more (it settles a freshly elapsed
|
||||
// period; a deferred close defers again, loudly), then seal so the
|
||||
// marker cannot be raced indefinitely and an in-flight sweep aborts
|
||||
// its conflicting close.
|
||||
let terminal = await claimTerminalPeriod(subscription.id)
|
||||
if (!terminal.markerWasCurrent) {
|
||||
await closeElapsedPeriodBeforeDeletion(subscription.id)
|
||||
terminal = await claimTerminalPeriod(subscription.id, { sealLagging: true })
|
||||
}
|
||||
const settlementPeriod = {
|
||||
periodStart: terminal.periodStart ?? subscription.periodStart ?? null,
|
||||
periodEnd: terminal.periodEnd ?? subscription.periodEnd ?? null,
|
||||
}
|
||||
|
||||
const totalOverage = await calculateSubscriptionOverage({
|
||||
...subscription,
|
||||
...settlementPeriod,
|
||||
})
|
||||
const stripe = requireStripeClient()
|
||||
|
||||
if (isEnterprise(subscription.plan)) {
|
||||
await resetUsageForSubscription({
|
||||
await writeFinalPeriodBookkeeping({
|
||||
id: subscription.id,
|
||||
plan: subscription.plan,
|
||||
referenceId: subscription.referenceId,
|
||||
periodStart: subscription.periodStart ?? null,
|
||||
periodEnd: subscription.periodEnd ?? null,
|
||||
billingInterval: subscription.billingInterval ?? null,
|
||||
...settlementPeriod,
|
||||
metadata: subscription.metadata,
|
||||
})
|
||||
|
||||
const dormantResult = await transitionOrganizationToDormantState(
|
||||
@@ -343,7 +359,15 @@ export async function handleSubscriptionDeleted(
|
||||
return { totalOverage: 0, kind: 'enterprise' as const }
|
||||
}
|
||||
|
||||
const billedOverage = await getBilledOverageForSubscription(subscription)
|
||||
// The tracker only ever holds collections for the period that began
|
||||
// at the close marker — the threshold gate blocks settlement while
|
||||
// the marker lags. If the marker was still lagging at claim time
|
||||
// (the elapsed close above deferred), the tracked amount belongs to
|
||||
// that forgiven elapsed period, not the terminal window: subtracting
|
||||
// it would under-bill the final invoice, so count nothing.
|
||||
const billedOverage = terminal.markerWasCurrent
|
||||
? await getBilledOverageForSubscription(subscription)
|
||||
: 0
|
||||
const remainingOverage = Math.max(0, totalOverage - billedOverage)
|
||||
|
||||
logger.info('Subscription deleted overage calculation', {
|
||||
@@ -423,11 +447,13 @@ export async function handleSubscriptionDeleted(
|
||||
})
|
||||
}
|
||||
|
||||
await resetUsageForSubscription({
|
||||
await writeFinalPeriodBookkeeping({
|
||||
id: subscription.id,
|
||||
plan: subscription.plan,
|
||||
referenceId: subscription.referenceId,
|
||||
periodStart: subscription.periodStart ?? null,
|
||||
periodEnd: subscription.periodEnd ?? null,
|
||||
billingInterval: subscription.billingInterval ?? null,
|
||||
...settlementPeriod,
|
||||
metadata: subscription.metadata,
|
||||
})
|
||||
|
||||
let restoredProCount = 0
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { db, dbFor } from '@sim/db'
|
||||
import {
|
||||
member,
|
||||
organization,
|
||||
usageLog,
|
||||
userStats,
|
||||
user as userTable,
|
||||
workflow,
|
||||
workflowExecutionLogs,
|
||||
@@ -1301,16 +1299,6 @@ export class ExecutionLogger implements IExecutionLoggerService {
|
||||
payerSubscription.plan,
|
||||
payerSubscription.seats
|
||||
)
|
||||
let orgBaseline = 0
|
||||
if (exactBillingContext.billingPeriod.source !== 'reporting') {
|
||||
const [{ sum }] = await db
|
||||
.select({ sum: sql`COALESCE(SUM(${userStats.currentPeriodCost}), 0)` })
|
||||
.from(member)
|
||||
.leftJoin(userStats, eq(member.userId, userStats.userId))
|
||||
.where(eq(member.organizationId, organizationId))
|
||||
.limit(1)
|
||||
orgBaseline = Number.parseFloat(String(sum ?? '0'))
|
||||
}
|
||||
const { getBillingPeriodUsageCost } = await import('@/lib/billing/core/usage-log')
|
||||
const orgLedger = await getBillingPeriodUsageCost(
|
||||
billingAttribution.billingEntity,
|
||||
@@ -1321,7 +1309,7 @@ export class ExecutionLogger implements IExecutionLoggerService {
|
||||
organizationId,
|
||||
planName: getDisplayPlanName(payerSubscription.plan),
|
||||
orgLimit,
|
||||
orgUsageBefore: orgBaseline + orgLedger,
|
||||
orgUsageBefore: orgLedger,
|
||||
}
|
||||
} else if (billingAttribution?.billingEntity.type === 'user' && usr?.email) {
|
||||
const sub = await getHighestPriorityPersonalSubscription(usr.id)
|
||||
|
||||
@@ -42,6 +42,9 @@ SHELL=/bin/sh
|
||||
# Microsoft Graph subscription renewal (Teams chat triggers expire after ~3 days)
|
||||
0 */12 * * * curl -fsS -m 120 -o /dev/null -H "Authorization: Bearer $CRON_SECRET" "$SIM_URL/api/cron/renew-subscriptions"
|
||||
|
||||
# Billing cycle close: final overage collection + per-period tracker reset for elapsed periods
|
||||
0 */6 * * * curl -fsS -m 120 -o /dev/null -H "Authorization: Bearer $CRON_SECRET" "$SIM_URL/api/cron/billing-cycle-close"
|
||||
|
||||
# Reclaims sandbox images
|
||||
30 4 * * * curl -fsS -m 300 -o /dev/null -H "Authorization: Bearer $CRON_SECRET" "$SIM_URL/api/cron/cleanup-sandbox-images"
|
||||
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@ apiVersion: v2
|
||||
name: sim
|
||||
description: A Helm chart for Sim - the open-source AI workspace where teams build, deploy, and manage AI agents
|
||||
type: application
|
||||
version: 1.5.4
|
||||
version: 1.6.0
|
||||
appVersion: "v0.7.44"
|
||||
kubeVersion: ">=1.25.0-0"
|
||||
home: https://sim.ai
|
||||
|
||||
@@ -1387,6 +1387,15 @@ cronjobs:
|
||||
successfulJobsHistoryLimit: 3
|
||||
failedJobsHistoryLimit: 1
|
||||
|
||||
billingCycleClose:
|
||||
enabled: true
|
||||
name: billing-cycle-close
|
||||
schedule: "0 */6 * * *"
|
||||
path: "/api/cron/billing-cycle-close"
|
||||
concurrencyPolicy: Forbid
|
||||
successfulJobsHistoryLimit: 3
|
||||
failedJobsHistoryLimit: 1
|
||||
|
||||
reconcileBillingSeats:
|
||||
enabled: true
|
||||
name: reconcile-billing-seats
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "subscription" ADD COLUMN "last_closed_period_start" timestamp;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2129,6 +2129,13 @@
|
||||
"when": 1787598098228,
|
||||
"tag": "0304_slippery_carmella_unuscione",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 305,
|
||||
"version": "7",
|
||||
"when": 1787687900983,
|
||||
"tag": "0305_add_subscription_last_closed_period_start",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+23
-13
@@ -1196,23 +1196,22 @@ export const userStats = pgTable('user_stats', {
|
||||
totalCost: decimal('total_cost').notNull().default('0'),
|
||||
currentUsageLimit: decimal('current_usage_limit').default(DEFAULT_FREE_CREDITS.toString()), // Default $5 (1,000 credits) for free plan, null for team/enterprise
|
||||
usageLimitUpdatedAt: timestamp('usage_limit_updated_at').defaultNow(),
|
||||
/**
|
||||
* Active per-period baseline (not a per-usage hot-path counter). Current usage
|
||||
* = this baseline + attributed usage_log rows for the period; reset at rollover.
|
||||
*/
|
||||
/** @deprecated No readers or writers; usage is the attributed usage_log ledger. Drop via DROP COLUMN in a follow-up migration. */
|
||||
currentPeriodCost: decimal('current_period_cost').notNull().default('0'),
|
||||
lastPeriodCost: decimal('last_period_cost').default('0'), // Usage from previous billing period
|
||||
/** Previous-period usage; written by the cycle-close sweep from ledger sums. */
|
||||
lastPeriodCost: decimal('last_period_cost').default('0'),
|
||||
/**
|
||||
* Threshold/final billing tracker.
|
||||
*
|
||||
* This is intentionally still written when threshold billing or invoice
|
||||
* finalization serializes overage collection. It is not incremented by the
|
||||
* ordinary per-usage ledger write path.
|
||||
* Incremented when threshold billing collects overage mid-period; reset to
|
||||
* zero by the cycle-close sweep at period rollover. It is not incremented
|
||||
* by the ordinary per-usage ledger write path.
|
||||
*/
|
||||
billedOverageThisPeriod: decimal('billed_overage_this_period').notNull().default('0'), // Amount of overage already billed via threshold billing
|
||||
// Pro usage snapshot when joining a team (to prevent double-billing)
|
||||
proPeriodCostSnapshot: decimal('pro_period_cost_snapshot').default('0'), // Snapshot of Pro usage when joining team
|
||||
proPeriodCostSnapshotAt: timestamp('pro_period_cost_snapshot_at'), // When the snapshot was captured (= join moment). Used to cap daily-refresh computation so post-join refresh isn't deducted from pre-join personal Pro usage (and vice-versa for the org's pooled refresh).
|
||||
/** @deprecated No readers or writers; ledger entity stamps attribute pre/post-join usage. Drop via DROP COLUMN in a follow-up migration. */
|
||||
proPeriodCostSnapshot: decimal('pro_period_cost_snapshot').default('0'),
|
||||
/** @deprecated No readers or writers; see proPeriodCostSnapshot. Drop via DROP COLUMN in a follow-up migration. */
|
||||
proPeriodCostSnapshotAt: timestamp('pro_period_cost_snapshot_at'),
|
||||
/**
|
||||
* Credit balance tracker.
|
||||
*
|
||||
@@ -1222,9 +1221,9 @@ export const userStats = pgTable('user_stats', {
|
||||
creditBalance: decimal('credit_balance').notNull().default('0'),
|
||||
/** @deprecated Not written; report Copilot cost from usage_log. Legacy/admin reads only. */
|
||||
totalCopilotCost: decimal('total_copilot_cost').notNull().default('0'),
|
||||
/** Active per-period Copilot baseline; reset at rollover (not a per-usage counter). */
|
||||
/** @deprecated No readers or writers; Copilot usage is the copilot-source usage_log ledger. Drop via DROP COLUMN in a follow-up migration. */
|
||||
currentPeriodCopilotCost: decimal('current_period_copilot_cost').notNull().default('0'),
|
||||
/** Previous-period Copilot cost; set at rollover. */
|
||||
/** Previous-period Copilot cost; written by the cycle-close sweep from copilot-source ledger sums. */
|
||||
lastPeriodCopilotCost: decimal('last_period_copilot_cost').default('0'),
|
||||
/** @deprecated Not written; report Copilot tokens from usage_log. Legacy/admin reads only. */
|
||||
totalCopilotTokens: bigint('total_copilot_tokens', { mode: 'number' }).notNull().default(0),
|
||||
@@ -1362,6 +1361,16 @@ export const subscription = pgTable(
|
||||
billingInterval: text('billing_interval'),
|
||||
stripeScheduleId: text('stripe_schedule_id'),
|
||||
metadata: json('metadata'),
|
||||
/**
|
||||
* Durable cycle-close marker: the `periodStart` of the most recent period
|
||||
* whose close (final overage collection, `billedOverageThisPeriod` reset,
|
||||
* last-period bookkeeping) has been committed. The daily cycle-close sweep
|
||||
* closes the previous period whenever this lags the row's `periodStart`,
|
||||
* then advances it. Null = never initialized; the first sweep initializes
|
||||
* it to the current `periodStart` without billing so historical periods
|
||||
* are never retroactively closed.
|
||||
*/
|
||||
lastClosedPeriodStart: timestamp('last_closed_period_start'),
|
||||
},
|
||||
(table) => ({
|
||||
referenceStatusIdx: index('subscription_reference_status_idx').on(
|
||||
@@ -1582,6 +1591,7 @@ export const organization = pgTable('organization', {
|
||||
.$type<Record<string, number>>()
|
||||
.notNull()
|
||||
.default({}),
|
||||
/** @deprecated No readers or writers; a departed member's ledger rows stay stamped to the org's period, so nothing needs capturing. Drop via DROP COLUMN in a follow-up migration. */
|
||||
departedMemberUsage: decimal('departed_member_usage').notNull().default('0'),
|
||||
/**
|
||||
* Organization credit balance tracker.
|
||||
|
||||
@@ -520,6 +520,7 @@ export const schemaMock = {
|
||||
trialStart: 'subscription.trialStart',
|
||||
trialEnd: 'subscription.trialEnd',
|
||||
metadata: 'subscription.metadata',
|
||||
lastClosedPeriodStart: 'subscription.lastClosedPeriodStart',
|
||||
},
|
||||
rateLimitBucket: {
|
||||
key: 'rateLimitBucket.key',
|
||||
|
||||
@@ -89,6 +89,7 @@ const INDIRECT_ZOD_ROUTES = new Set([
|
||||
'apps/sim/app/api/cron/cleanup-stale-executions/route.ts',
|
||||
'apps/sim/app/api/cron/cleanup-sandbox-images/route.ts',
|
||||
'apps/sim/app/api/cron/renew-subscriptions/route.ts',
|
||||
'apps/sim/app/api/cron/billing-cycle-close/route.ts',
|
||||
'apps/sim/app/api/cron/reconcile-billing-seats/route.ts',
|
||||
'apps/sim/app/api/cron/reconcile-inbox-entitlement/route.ts',
|
||||
'apps/sim/app/api/cron/run-data-drains/route.ts',
|
||||
|
||||
Reference in New Issue
Block a user