2026-07-13 01:15:30 +00:00
|
|
|
|
package domain
|
|
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
|
"errors"
|
2026-07-13 03:18:08 +00:00
|
|
|
|
"strings"
|
2026-07-13 01:15:30 +00:00
|
|
|
|
"time"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
const (
|
|
|
|
|
|
KeyModePlatform = "platform"
|
|
|
|
|
|
KeyModeByok = "byok"
|
|
|
|
|
|
|
|
|
|
|
|
MeterAICopy = "ai_copy"
|
|
|
|
|
|
MeterAIResearch = "ai_research"
|
|
|
|
|
|
MeterWebSearch = "web_search"
|
|
|
|
|
|
MeterAIImage = "ai_image"
|
|
|
|
|
|
|
|
|
|
|
|
PlanFree = "free"
|
|
|
|
|
|
PlanStarter = "starter"
|
|
|
|
|
|
PlanPro = "pro"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
var (
|
|
|
|
|
|
ErrNotFound = errors.New("usage not found")
|
|
|
|
|
|
ErrForbidden = errors.New("usage forbidden")
|
|
|
|
|
|
ErrNoKey = errors.New("no api key configured (platform or byok)")
|
|
|
|
|
|
ErrQuotaExceeded = errors.New("platform credits insufficient")
|
2026-07-13 03:18:08 +00:00
|
|
|
|
// 分項點數上限(與 FE soft_caps 對齊;platform only)
|
|
|
|
|
|
ErrMeterCapExceeded = errors.New("usage.err.meterCap")
|
|
|
|
|
|
// 平台 key 無容量/限流:僅 BYOK 可繼續(不扣平台點)
|
|
|
|
|
|
ErrPlatformCapacity = errors.New("usage.err.platformCapacity")
|
|
|
|
|
|
ErrInvalidPlan = errors.New("invalid plan")
|
2026-07-15 15:23:59 +00:00
|
|
|
|
ErrInvalidRange = errors.New("invalid usage analytics range")
|
|
|
|
|
|
ErrPurchaseDisabled = errors.New("self-service plan purchase is disabled; use billing checkout")
|
2026-07-13 01:15:30 +00:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
type Event struct {
|
|
|
|
|
|
ID string `bson:"_id" json:"id"`
|
|
|
|
|
|
UID int64 `bson:"uid" json:"uid"`
|
|
|
|
|
|
Meter string `bson:"meter" json:"meter"`
|
|
|
|
|
|
Credits int `bson:"credits" json:"credits"` // platform only; byok always 0
|
|
|
|
|
|
KeyMode string `bson:"key_mode" json:"key_mode"`
|
|
|
|
|
|
Label string `bson:"label" json:"label"`
|
|
|
|
|
|
Source string `bson:"source" json:"source"`
|
|
|
|
|
|
MonthKey string `bson:"month_key" json:"month_key"`
|
|
|
|
|
|
CreatedAt int64 `bson:"created_at" json:"created_at"`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-15 15:23:59 +00:00
|
|
|
|
type MonthlyCounter struct {
|
|
|
|
|
|
ID string `bson:"_id" json:"id"`
|
|
|
|
|
|
UID int64 `bson:"uid" json:"uid"`
|
|
|
|
|
|
MonthKey string `bson:"month_key" json:"month_key"`
|
|
|
|
|
|
TotalCredits int `bson:"total_credits" json:"total_credits"`
|
|
|
|
|
|
ByMeter map[string]int `bson:"by_meter" json:"by_meter"`
|
|
|
|
|
|
UpdatedAt int64 `bson:"updated_at" json:"updated_at"`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-13 01:15:30 +00:00
|
|
|
|
type MemberPrefs struct {
|
2026-07-15 15:23:59 +00:00
|
|
|
|
UID int64 `bson:"uid" json:"uid"`
|
|
|
|
|
|
PlanID string `bson:"plan_id" json:"plan_id"`
|
|
|
|
|
|
Unlimited bool `bson:"unlimited" json:"unlimited"`
|
|
|
|
|
|
AdminOverride bool `bson:"admin_override" json:"admin_override"`
|
|
|
|
|
|
PaidPlanID string `bson:"paid_plan_id,omitempty" json:"paid_plan_id,omitempty"`
|
|
|
|
|
|
SubscriptionStatus string `bson:"subscription_status,omitempty" json:"subscription_status,omitempty"`
|
|
|
|
|
|
StripeCustomerID string `bson:"stripe_customer_id,omitempty" json:"stripe_customer_id,omitempty"`
|
|
|
|
|
|
StripeSubscriptionID string `bson:"stripe_subscription_id,omitempty" json:"stripe_subscription_id,omitempty"`
|
|
|
|
|
|
CurrentPeriodStart int64 `bson:"current_period_start,omitempty" json:"current_period_start,omitempty"`
|
|
|
|
|
|
CurrentPeriodEnd int64 `bson:"current_period_end,omitempty" json:"current_period_end,omitempty"`
|
|
|
|
|
|
CancelAtPeriodEnd bool `bson:"cancel_at_period_end" json:"cancel_at_period_end"`
|
|
|
|
|
|
BillingEventID string `bson:"billing_event_id,omitempty" json:"-"`
|
|
|
|
|
|
BillingEventCreatedAt int64 `bson:"billing_event_created_at,omitempty" json:"-"`
|
|
|
|
|
|
UpdatedAt int64 `bson:"updated_at" json:"updated_at"`
|
2026-07-13 01:15:30 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type Purchase struct {
|
|
|
|
|
|
ID string `bson:"_id" json:"id"`
|
|
|
|
|
|
UID int64 `bson:"uid" json:"uid"`
|
|
|
|
|
|
PlanID string `bson:"plan_id" json:"plan_id"`
|
|
|
|
|
|
MockRef string `bson:"mock_ref,omitempty" json:"mock_ref,omitempty"`
|
|
|
|
|
|
CreatedAt int64 `bson:"created_at" json:"created_at"`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type PlanDef struct {
|
|
|
|
|
|
ID string
|
|
|
|
|
|
MonthlyCredits int
|
2026-07-13 03:18:08 +00:00
|
|
|
|
// SoftCaps:各 meter 點數硬上限(platform);加總 = MonthlyCredits
|
|
|
|
|
|
SoftCaps map[string]int
|
2026-07-13 01:15:30 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-13 03:18:08 +00:00
|
|
|
|
// Plans — 與 FE PLANS 同步(2026-07 · 組合毛利 ≥50%)
|
|
|
|
|
|
// 安全單價約 $0.012/點;Starter NT$590≈$18.4 / Pro NT$1990≈$62.2
|
|
|
|
|
|
// Free 120 點:夠用試用(可虧 ~$1.44 滿用);付費扛回組合毛利。
|
2026-07-13 01:15:30 +00:00
|
|
|
|
var Plans = map[string]PlanDef{
|
2026-07-13 03:18:08 +00:00
|
|
|
|
PlanFree: {
|
|
|
|
|
|
ID: PlanFree, MonthlyCredits: 120,
|
|
|
|
|
|
SoftCaps: map[string]int{
|
|
|
|
|
|
MeterAICopy: 60, MeterAIResearch: 15, MeterWebSearch: 30, MeterAIImage: 15,
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
PlanStarter: {
|
|
|
|
|
|
ID: PlanStarter, MonthlyCredits: 600,
|
|
|
|
|
|
SoftCaps: map[string]int{
|
|
|
|
|
|
MeterAICopy: 300, MeterAIResearch: 90, MeterWebSearch: 150, MeterAIImage: 60,
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
PlanPro: {
|
|
|
|
|
|
ID: PlanPro, MonthlyCredits: 2000,
|
|
|
|
|
|
SoftCaps: map[string]int{
|
|
|
|
|
|
MeterAICopy: 1000, MeterAIResearch: 300, MeterWebSearch: 500, MeterAIImage: 200,
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
2026-07-13 01:15:30 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var DefaultCreditCost = map[string]int{
|
|
|
|
|
|
MeterAICopy: 1, MeterAIResearch: 3, MeterWebSearch: 1, MeterAIImage: 5,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-13 03:18:08 +00:00
|
|
|
|
// ResolvePlan — 正規化 plan_id(小寫)並回傳方案;未知 → Free
|
|
|
|
|
|
func ResolvePlan(planID string) PlanDef {
|
|
|
|
|
|
id := strings.ToLower(strings.TrimSpace(planID))
|
|
|
|
|
|
if p, ok := Plans[id]; ok && p.ID != "" {
|
|
|
|
|
|
return p
|
|
|
|
|
|
}
|
|
|
|
|
|
return Plans[PlanFree]
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// MeterCost returns platform credit cost for one call.
|
|
|
|
|
|
func MeterCost(meter string) int {
|
|
|
|
|
|
if c := DefaultCreditCost[meter]; c > 0 {
|
|
|
|
|
|
return c
|
|
|
|
|
|
}
|
|
|
|
|
|
return 1
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-13 01:15:30 +00:00
|
|
|
|
func NowNano() int64 { return time.Now().UTC().UnixNano() }
|
|
|
|
|
|
|
|
|
|
|
|
func MonthKey(nano int64) string {
|
|
|
|
|
|
t := time.Unix(0, nano).UTC()
|
|
|
|
|
|
return t.Format("2006-01")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func CurrentMonthKey() string { return MonthKey(NowNano()) }
|
|
|
|
|
|
|
|
|
|
|
|
// Summary split — never merge platform+byok into one total_calls only.
|
|
|
|
|
|
type MeterCount struct {
|
|
|
|
|
|
Count int `json:"count"`
|
|
|
|
|
|
Credits int `json:"credits"`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type PlatformBlock struct {
|
|
|
|
|
|
CreditsUsed int `json:"credits_used"`
|
|
|
|
|
|
CreditsRemaining int `json:"credits_remaining"`
|
|
|
|
|
|
CreditsTotal int `json:"credits_total"`
|
|
|
|
|
|
CallCount int `json:"call_count"`
|
|
|
|
|
|
ByMeter map[string]MeterCount `json:"by_meter"`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type ByokBlock struct {
|
|
|
|
|
|
CallCount int `json:"call_count"`
|
|
|
|
|
|
ByMeter map[string]MeterCount `json:"by_meter"`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type MonthSummary struct {
|
|
|
|
|
|
MonthKey string `json:"month_key"`
|
|
|
|
|
|
UID int64 `json:"uid"`
|
|
|
|
|
|
PlanID string `json:"plan_id"`
|
|
|
|
|
|
Unlimited bool `json:"unlimited"`
|
|
|
|
|
|
Platform PlatformBlock `json:"platform"`
|
|
|
|
|
|
Byok ByokBlock `json:"byok"`
|
|
|
|
|
|
// Legacy FE bars: only platform credits (not mixed)
|
|
|
|
|
|
TotalCredits int `json:"total_credits"`
|
|
|
|
|
|
RemainingCredits int `json:"remaining_credits"`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type TenantRow struct {
|
2026-07-15 15:23:59 +00:00
|
|
|
|
UID int64 `json:"uid"`
|
|
|
|
|
|
PlanID string `json:"plan_id"`
|
|
|
|
|
|
Unlimited bool `json:"unlimited"`
|
|
|
|
|
|
PlatformCreditsUsed int `json:"platform_credits_used"`
|
|
|
|
|
|
ByokCallCount int `json:"byok_call_count"`
|
2026-07-13 01:15:30 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type TenantSummary struct {
|
2026-07-15 15:23:59 +00:00
|
|
|
|
MonthKey string `json:"month_key"`
|
|
|
|
|
|
PlatformCreditsUsed int `json:"platform_credits_used"`
|
|
|
|
|
|
ByokCallCount int `json:"byok_call_count"`
|
|
|
|
|
|
Members []TenantRow `json:"members"`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type AnalyticsMember struct {
|
|
|
|
|
|
UID int64
|
|
|
|
|
|
Email string
|
|
|
|
|
|
DisplayName string
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type AnalyticsQuery struct {
|
|
|
|
|
|
Granularity string
|
|
|
|
|
|
From time.Time
|
|
|
|
|
|
To time.Time
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type AnalyticsBucket struct {
|
|
|
|
|
|
Key string
|
|
|
|
|
|
Label string
|
|
|
|
|
|
PurchasedCredits int
|
|
|
|
|
|
ConsumedCredits int
|
|
|
|
|
|
AICalls int
|
|
|
|
|
|
SearchCalls int
|
|
|
|
|
|
ByokCallCount int
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type AnalyticsMemberRow struct {
|
|
|
|
|
|
UID int64
|
|
|
|
|
|
Email string
|
|
|
|
|
|
DisplayName string
|
|
|
|
|
|
Unlimited bool
|
|
|
|
|
|
PlanID string
|
|
|
|
|
|
PurchasedCredits int
|
|
|
|
|
|
TotalCredits int
|
|
|
|
|
|
AICalls int
|
|
|
|
|
|
SearchCalls int
|
|
|
|
|
|
ByokCallCount int
|
|
|
|
|
|
RemainingCredits int
|
|
|
|
|
|
Pct int
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type TenantAnalytics struct {
|
|
|
|
|
|
Granularity string
|
|
|
|
|
|
From string
|
|
|
|
|
|
To string
|
|
|
|
|
|
RangeLabel string
|
|
|
|
|
|
PurchasedCredits int
|
|
|
|
|
|
ConsumedCredits int
|
|
|
|
|
|
RemainingCredits int
|
|
|
|
|
|
Pct int
|
|
|
|
|
|
AICalls int
|
|
|
|
|
|
SearchCalls int
|
|
|
|
|
|
ByMeter map[string]MeterCount
|
|
|
|
|
|
Byok ByokBlock
|
|
|
|
|
|
Series []AnalyticsBucket
|
|
|
|
|
|
Members []AnalyticsMemberRow
|
2026-07-13 01:15:30 +00:00
|
|
|
|
}
|