@@ -39,6 +39,7 @@ import {
3939import { getMeteringClient } from '@/lib/billing/aws-marketplace/client'
4040import { getAwsMarketplaceProducts } from '@/lib/billing/aws-marketplace/config'
4141import type { AwsMarketplaceUsagePricing } from '@/lib/billing/aws-marketplace/products'
42+ import { MAX_WORKFLOW_EXECUTION_TIMEOUT_SECONDS } from '@/lib/billing/execution-timeout-defaults'
4243import { toDecimal , toFixedString } from '@/lib/billing/utils/decimal'
4344import { acquireAdvisoryXactLock } from '@/lib/db/advisory-locks'
4445import type { DbTransaction } from '@/lib/db/types'
@@ -53,10 +54,10 @@ const MONTH_GRACE_MS = 6 * HOUR_MS
5354/** AWS takes a license's last usage for about an hour after it ends. */
5455const AFTER_END_MS = HOUR_MS
5556/**
56- * A billing period stops changing once it ends, since a request that outlives it records the rest
57- * in the next period; a day of margin covers a request still finishing as the period turns .
57+ * How long a usage row's cost can keep growing: a request tops up its row in place while it runs,
58+ * and no run outlasts the longest execution timeout. A day of margin covers late cost callbacks .
5859 */
59- const PERIOD_SETTLE_MS = 24 * HOUR_MS
60+ const ROW_SETTLE_MS = MAX_WORKFLOW_EXECUTION_TIMEOUT_SECONDS * 1000 + 24 * HOUR_MS
6061/** BatchMeterUsage takes at most 25 records. */
6162const RECORDS_PER_CALL = 25
6263/** Rows sent per run, oldest first; any beyond it go in the next run. */
@@ -97,7 +98,7 @@ interface MeteredLicense {
9798/** A ledger row awaiting AWS, with the license it bills. */
9899interface PendingReport {
99100 id : string
100- organizationId : string
101+ organizationId : string | null
101102 dimension : string
102103 hourStart : Date
103104 quantity : number
@@ -170,30 +171,43 @@ async function listMeteredLicenses(
170171 } )
171172}
172173
174+ /**
175+ * When a license stopped being in force, or null while it still is: the earlier of its expiry, once
176+ * passed, and the moment a check first found it inactive.
177+ */
178+ function licenseEnd ( license : MeteredLicense , now : number ) : number | null {
179+ const expiry = license . expiresAt ?. getTime ( )
180+ const ends = [
181+ ...( expiry !== undefined && expiry <= now ? [ expiry ] : [ ] ) ,
182+ ...( license . status === 'active' ? [ ] : [ license . endedAt ?. getTime ( ) ?? now ] ) ,
183+ ]
184+ return ends . length > 0 ? Math . min ( ...ends ) : null
185+ }
186+
173187/**
174188 * The license an organization's newly counted usage is charged to, and the hour it is charged in.
175189 * A license still in force wins, the earliest-linked first, so overlapping licenses during a
176- * renewal never both bill. With none in force, a license that ended within the hour AWS allows
177- * takes the usage at the hour it ended, until that record has gone to AWS.
190+ * renewal never both bill. With none in force, the license that ended most recently, within the
191+ * hour AWS allows, takes the usage at the hour it ended. That record is sent as soon as the end is
192+ * known, because AWS counts its hour from the unsubscribe itself, which a check only finds later:
193+ * usage that lands after the send, from work still finishing once access is gone, is not billed.
178194 */
179195function chargeTarget (
180196 licenses : MeteredLicense [ ] ,
181197 now : number
182198) : { license : MeteredLicense ; hourStart : number } | null {
183- const inForce = licenses . find (
184- ( license ) =>
185- license . status === 'active' && ( ! license . expiresAt || license . expiresAt . getTime ( ) > now )
186- )
187- if ( inForce ) return { license : inForce , hourStart : floorHour ( now ) }
188- const ended = licenses
189- . filter ( ( license ) => license . endedAt && license . endedAt . getTime ( ) >= now - AFTER_END_MS )
190- . sort ( ( left , right ) => ( right . endedAt ?. getTime ( ) ?? 0 ) - ( left . endedAt ?. getTime ( ) ?? 0 ) ) [ 0 ]
191- return ended ?. endedAt ? { license : ended , hourStart : floorHour ( ended . endedAt . getTime ( ) ) } : null
199+ let ended : { license : MeteredLicense ; end : number } | null = null
200+ for ( const license of licenses ) {
201+ const end = licenseEnd ( license , now )
202+ if ( end === null ) return { license, hourStart : floorHour ( now ) }
203+ if ( end >= now - AFTER_END_MS && ( ! ended || end > ended . end ) ) ended = { license, end }
204+ }
205+ return ended ? { license : ended . license , hourStart : floorHour ( ended . end ) } : null
192206}
193207
194208/**
195- * Counts the organization's usage in each billing period still open to change, and returns the
196- * whole units not yet charged. Each period's tally is updated to what it now covers; a period's
209+ * Counts the organization's usage in each billing period with a row still able to change, and
210+ * returns the whole units not yet charged. Each period's tally is updated to what it now covers; a period's
197211 * first tally fixes where its count starts.
198212 */
199213async function countNewUnits (
@@ -221,7 +235,7 @@ async function countNewUnits(
221235 and (
222236 eq ( usageLog . billingEntityType , 'organization' ) ,
223237 eq ( usageLog . billingEntityId , organizationId ) ,
224- gt ( usageLog . billingPeriodEnd , new Date ( now - PERIOD_SETTLE_MS ) )
238+ gt ( usageLog . createdAt , new Date ( now - ROW_SETTLE_MS ) )
225239 )
226240 )
227241
@@ -391,9 +405,13 @@ function recordKey(dimension: string | undefined, timestamp: Date | undefined):
391405 */
392406async function freezeForSending ( rows : PendingReport [ ] ) : Promise < PendingReport [ ] > {
393407 return db . transaction ( async ( tx ) => {
394- const scopes = new Map ( rows . map ( ( row ) => [ `${ row . organizationId } :${ row . dimension } ` , row ] ) )
408+ const scopes = new Map < string , { organizationId : string ; dimension : string } > ( )
409+ for ( const { organizationId, dimension } of rows ) {
410+ if ( organizationId )
411+ scopes . set ( `${ organizationId } :${ dimension } ` , { organizationId, dimension } )
412+ }
395413 const ordered = [ ...scopes ] . sort ( ( [ left ] , [ right ] ) => compareStrings ( left , right ) )
396- for ( const [ , row ] of ordered ) await lockUsage ( tx , row . organizationId , row . dimension )
414+ for ( const [ , scope ] of ordered ) await lockUsage ( tx , scope . organizationId , scope . dimension )
397415 const frozen = await tx
398416 . update ( awsMarketplaceUsageReport )
399417 . set ( { sentAt : sql `coalesce(${ awsMarketplaceUsageReport . sentAt } , now())` } )
@@ -483,8 +501,8 @@ async function sendBatch(
483501}
484502
485503/**
486- * Sends every pending row whose hour has closed, or whose license has ended so no more usage can
487- * join it, oldest first and grouped by license.
504+ * Sends every pending row whose hour has closed, or whose license has ended or expired so no more
505+ * usage can join it, oldest first and grouped by license.
488506 */
489507async function sendPendingReports ( now : number , summary : AwsMarketplaceMeteringSummary ) {
490508 summary . rejected += await rejectExpiredReports ( now )
@@ -508,7 +526,8 @@ async function sendPendingReports(now: number, summary: AwsMarketplaceMeteringSu
508526 eq ( awsMarketplaceUsageReport . status , 'pending' ) ,
509527 or (
510528 lt ( awsMarketplaceUsageReport . hourStart , new Date ( floorHour ( now ) ) ) ,
511- eq ( awsMarketplaceLicense . status , 'inactive' )
529+ eq ( awsMarketplaceLicense . status , 'inactive' ) ,
530+ lte ( awsMarketplaceLicense . expiresAt , new Date ( now ) )
512531 )
513532 )
514533 )
0 commit comments