fix(quota): spend overage credits against the weekly window and count 7 days, not 8
This commit is contained in:
parent
f4f9653361
commit
102f3ef934
5 changed files with 601 additions and 65 deletions
|
|
@ -10,6 +10,13 @@ import {
|
|||
type PlanQuotaPeriod,
|
||||
type PlanQuotaTier,
|
||||
} from './core-contract.generated.ts'
|
||||
import {
|
||||
isQuotaAllowed,
|
||||
parseQuotaConsumeResponse,
|
||||
type QuotaConsumedFrom,
|
||||
quotaUsageDate,
|
||||
quotaWindowStart,
|
||||
} from './quota-policy.ts'
|
||||
|
||||
export type Tier = PlanQuotaTier
|
||||
|
||||
|
|
@ -50,7 +57,7 @@ export interface QuotaConsumeResult {
|
|||
current: number
|
||||
limit: number
|
||||
overageCredits: number
|
||||
consumedFrom: 'base' | 'overage' | 'unlimited' | 'none'
|
||||
consumedFrom: QuotaConsumedFrom
|
||||
}
|
||||
|
||||
export interface SttQuotaReservation {
|
||||
|
|
@ -123,65 +130,75 @@ export async function finalizeSttQuota(
|
|||
return status
|
||||
}
|
||||
|
||||
/**
|
||||
* 쿼터 확인 — 모델별, 기간별(daily/weekly).
|
||||
* weekly인 경우 최근 7일 daily_usage를 합산.
|
||||
*/
|
||||
export async function checkQuota(
|
||||
interface SubscriptionQuotaState {
|
||||
tier: Tier
|
||||
overageCredits: number
|
||||
}
|
||||
|
||||
/** 구독 티어 + overage 잔액. 행이 없으면 free/0 (기존 checkQuota 동작 유지). */
|
||||
async function readSubscriptionQuotaState(
|
||||
userId: string,
|
||||
feature: QuotaFeature,
|
||||
serviceRoleClient: ReturnType<typeof createClient>,
|
||||
): Promise<QuotaCheck> {
|
||||
// 티어 + overage 조회
|
||||
): Promise<SubscriptionQuotaState> {
|
||||
const { data: sub } = await serviceRoleClient
|
||||
.from('subscriptions')
|
||||
.select('tier, overage_credits')
|
||||
.eq('user_id', userId)
|
||||
.single()
|
||||
return {
|
||||
tier: (sub?.tier as Tier) ?? 'free',
|
||||
overageCredits: (sub?.overage_credits as number) ?? 0,
|
||||
}
|
||||
}
|
||||
|
||||
const tier: Tier = (sub?.tier as Tier) ?? 'free'
|
||||
const overageCredits = (sub?.overage_credits as number) ?? 0
|
||||
/** 기간 창(daily=오늘, weekly=오늘-6..오늘) 안의 daily_usage 합계. */
|
||||
async function readWindowUsage(
|
||||
userId: string,
|
||||
feature: QuotaFeature,
|
||||
period: QuotaPeriod,
|
||||
serviceRoleClient: ReturnType<typeof createClient>,
|
||||
now: Date,
|
||||
): Promise<number> {
|
||||
const { data: rows } = await serviceRoleClient
|
||||
.from('daily_usage')
|
||||
.select('count')
|
||||
.eq('user_id', userId)
|
||||
.eq('feature', feature)
|
||||
.gte('date', quotaWindowStart(period, now))
|
||||
.lte('date', quotaUsageDate(now))
|
||||
const usageRows = (rows ?? []) as Array<{ count: number | null }>
|
||||
return usageRows.reduce((sum, row) => sum + (row.count ?? 0), 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* 쿼터 확인 — 모델별, 기간별(daily/weekly).
|
||||
* weekly인 경우 최근 7일(오늘 포함, 오늘-6..오늘) daily_usage를 합산 —
|
||||
* SQL 쪽 `consume_quota`·`reserve_stt_quota`의 `CURRENT_DATE - 6` 창과 같다.
|
||||
*/
|
||||
export async function checkQuota(
|
||||
userId: string,
|
||||
feature: QuotaFeature,
|
||||
serviceRoleClient: ReturnType<typeof createClient>,
|
||||
now: Date = new Date(),
|
||||
): Promise<QuotaCheck> {
|
||||
const { tier, overageCredits } = await readSubscriptionQuotaState(userId, serviceRoleClient)
|
||||
const policy = getQuotaPolicy(tier, feature)
|
||||
|
||||
// 사용불가 (limit=0)
|
||||
if (policy.limit === 0) {
|
||||
return { allowed: false, current: 0, limit: 0, period: policy.period, tier, overageCredits }
|
||||
}
|
||||
|
||||
// 무제한
|
||||
if (policy.limit === -1) {
|
||||
return { allowed: true, current: 0, limit: -1, period: policy.period, tier, overageCredits }
|
||||
}
|
||||
|
||||
// 사용량 조회 (daily vs weekly)
|
||||
let current: number
|
||||
if (policy.period === 'weekly') {
|
||||
// 최근 7일 합산
|
||||
const weekAgo = new Date()
|
||||
weekAgo.setDate(weekAgo.getDate() - 7)
|
||||
const { data: rows } = await serviceRoleClient
|
||||
.from('daily_usage')
|
||||
.select('count')
|
||||
.eq('user_id', userId)
|
||||
.eq('feature', feature)
|
||||
.gte('date', weekAgo.toISOString().slice(0, 10))
|
||||
const usageRows = (rows ?? []) as Array<{ count: number | null }>
|
||||
current = usageRows.reduce((sum, row) => sum + (row.count ?? 0), 0)
|
||||
} else {
|
||||
// 오늘만
|
||||
const today = new Date().toISOString().slice(0, 10)
|
||||
const { data: usage } = await serviceRoleClient
|
||||
.from('daily_usage')
|
||||
.select('count')
|
||||
.eq('user_id', userId)
|
||||
.eq('date', today)
|
||||
.eq('feature', feature)
|
||||
.maybeSingle()
|
||||
current = (usage?.count as number) ?? 0
|
||||
// 사용불가 (limit=0) / 무제한 (limit=-1) 은 사용량 조회가 필요 없다.
|
||||
if (policy.limit === 0 || policy.limit === -1) {
|
||||
return {
|
||||
allowed: isQuotaAllowed(policy.limit, 0, overageCredits),
|
||||
current: 0,
|
||||
limit: policy.limit,
|
||||
period: policy.period,
|
||||
tier,
|
||||
overageCredits,
|
||||
}
|
||||
}
|
||||
|
||||
const current = await readWindowUsage(userId, feature, policy.period, serviceRoleClient, now)
|
||||
return {
|
||||
allowed: current < policy.limit || overageCredits > 0,
|
||||
allowed: isQuotaAllowed(policy.limit, current, overageCredits),
|
||||
current,
|
||||
limit: policy.limit,
|
||||
period: policy.period,
|
||||
|
|
@ -191,42 +208,36 @@ export async function checkQuota(
|
|||
}
|
||||
|
||||
/**
|
||||
* 쿼터 소비 — 항상 오늘 날짜의 daily_usage를 +1 증가.
|
||||
* (weekly 집계는 checkQuota에서 7일 합산으로 처리)
|
||||
* 쿼터 소비 — 오늘 날짜의 daily_usage를 +1 증가.
|
||||
* `consume_quota` RPC가 기간 창(weekly=최근 7일 합산) 사용량을 base 한도와 비교해
|
||||
* base 소진 + overage 있으면 overage 1 크레딧을 원자적으로 차감한다.
|
||||
* 무제한(-1)이면 카운터만 증가하고 allowed=true.
|
||||
* base 소진 + overage 있으면 overage 차감.
|
||||
*
|
||||
* `period`를 넘기지 않으면 구독 티어로 정책 기간을 다시 조회한다
|
||||
* (checkQuota 결과의 `period`를 넘기면 조회 1회를 아낀다).
|
||||
*/
|
||||
export async function consumeQuota(
|
||||
userId: string,
|
||||
feature: QuotaFeature,
|
||||
serviceRoleClient: ReturnType<typeof createClient>,
|
||||
baseLimit: number,
|
||||
period?: QuotaPeriod,
|
||||
): Promise<QuotaConsumeResult> {
|
||||
const resolvedPeriod = period
|
||||
?? getQuotaPolicy((await readSubscriptionQuotaState(userId, serviceRoleClient)).tier, feature).period
|
||||
|
||||
const { data, error } = await serviceRoleClient.rpc('consume_quota', {
|
||||
p_user_id: userId,
|
||||
p_feature: feature,
|
||||
p_base_limit: baseLimit,
|
||||
p_period: resolvedPeriod,
|
||||
})
|
||||
|
||||
if (error) {
|
||||
throw new Error(`Failed to consume quota: ${error.message}`)
|
||||
}
|
||||
|
||||
const result = data as {
|
||||
allowed: boolean
|
||||
current: number
|
||||
limit: number
|
||||
overage_credits: number
|
||||
consumed_from: 'base' | 'overage' | 'unlimited' | 'none'
|
||||
}
|
||||
|
||||
return {
|
||||
allowed: result.allowed,
|
||||
current: result.current,
|
||||
limit: result.limit,
|
||||
overageCredits: result.overage_credits,
|
||||
consumedFrom: result.consumed_from,
|
||||
}
|
||||
return parseQuotaConsumeResponse(data)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue