@nodaro/shared 2.3.0 → 2.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +38 -2
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +52 -3
- package/dist/index.d.ts +52 -3
- package/dist/index.js +35 -3
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/effective-tier.test.ts +116 -0
- package/src/effective-tier.ts +72 -0
- package/src/index.ts +7 -0
- package/src/model-constants.ts +6 -1
- package/src/pipeline-defaults.ts +2 -0
package/package.json
CHANGED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest"
|
|
2
|
+
import {
|
|
3
|
+
resolveStoredTier,
|
|
4
|
+
resolveEffectiveTier,
|
|
5
|
+
isPaygRetentionActive,
|
|
6
|
+
PAYG_RETENTION_DAYS,
|
|
7
|
+
} from "../effective-tier.js"
|
|
8
|
+
|
|
9
|
+
const DAY_MS = 24 * 60 * 60 * 1000
|
|
10
|
+
const NOW = new Date("2026-08-12T12:00:00Z")
|
|
11
|
+
const daysAgo = (n: number) => new Date(NOW.getTime() - n * DAY_MS)
|
|
12
|
+
|
|
13
|
+
describe("resolveStoredTier", () => {
|
|
14
|
+
it("prefers tier, falls back to subscription_tier, then free", () => {
|
|
15
|
+
expect(resolveStoredTier({ tier: "pro", subscription_tier: "basic" })).toBe("pro")
|
|
16
|
+
expect(resolveStoredTier({ tier: null, subscription_tier: "basic" })).toBe("basic")
|
|
17
|
+
expect(resolveStoredTier({ tier: null, subscription_tier: null })).toBe("free")
|
|
18
|
+
})
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
describe("resolveEffectiveTier — the payg derivation matrix (design §9)", () => {
|
|
22
|
+
it("never-paid free user stays free", () => {
|
|
23
|
+
expect(
|
|
24
|
+
resolveEffectiveTier({ tier: "free", subscription_tier: null, lifetime_topup_credits: 0 })
|
|
25
|
+
).toBe("free")
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it("free user with NET lifetime > 0 derives payg — even at zero current balance", () => {
|
|
29
|
+
expect(
|
|
30
|
+
resolveEffectiveTier({ tier: "free", subscription_tier: null, lifetime_topup_credits: 3300 })
|
|
31
|
+
).toBe("payg")
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it("refunded-to-zero user drops back to free (NET lifetime)", () => {
|
|
35
|
+
expect(
|
|
36
|
+
resolveEffectiveTier({ tier: "free", subscription_tier: null, lifetime_topup_credits: 0 })
|
|
37
|
+
).toBe("free")
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it("#489 pre-subscribe carryover fixture: topup balance without purchase stays free", () => {
|
|
41
|
+
// Cancel-path carryover moves a pre-subscribe balance into topup_credits
|
|
42
|
+
// with NO purchase — lifetime stays 0, so the user must NOT derive payg.
|
|
43
|
+
expect(
|
|
44
|
+
resolveEffectiveTier({ tier: "free", subscription_tier: null, lifetime_topup_credits: 0 })
|
|
45
|
+
).toBe("free")
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it("every stored paid tier passes through untouched, regardless of lifetime", () => {
|
|
49
|
+
for (const t of ["basic", "standard", "pro", "business"]) {
|
|
50
|
+
expect(
|
|
51
|
+
resolveEffectiveTier({ tier: t, subscription_tier: null, lifetime_topup_credits: 9999 })
|
|
52
|
+
).toBe(t)
|
|
53
|
+
}
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it("subscription_tier-only legacy rows resolve through the stored fallback", () => {
|
|
57
|
+
expect(
|
|
58
|
+
resolveEffectiveTier({ tier: null, subscription_tier: "standard", lifetime_topup_credits: 500 })
|
|
59
|
+
).toBe("standard")
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it("null-tier never-paid rows resolve free; with lifetime they derive payg", () => {
|
|
63
|
+
expect(
|
|
64
|
+
resolveEffectiveTier({ tier: null, subscription_tier: null, lifetime_topup_credits: 0 })
|
|
65
|
+
).toBe("free")
|
|
66
|
+
expect(
|
|
67
|
+
resolveEffectiveTier({ tier: null, subscription_tier: null, lifetime_topup_credits: 100 })
|
|
68
|
+
).toBe("payg")
|
|
69
|
+
})
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
describe("isPaygRetentionActive — 90-day activity window boundaries", () => {
|
|
73
|
+
it("exports the 90-day constant", () => {
|
|
74
|
+
expect(PAYG_RETENTION_DAYS).toBe(90)
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it("purchase activity: 89d active, 90d active (inclusive), 91d inactive", () => {
|
|
78
|
+
const base = { lifetimeTopupCredits: 100, lastSpendAt: null }
|
|
79
|
+
expect(isPaygRetentionActive({ ...base, lastTopupAt: daysAgo(89) }, NOW)).toBe(true)
|
|
80
|
+
expect(isPaygRetentionActive({ ...base, lastTopupAt: daysAgo(90) }, NOW)).toBe(true)
|
|
81
|
+
expect(isPaygRetentionActive({ ...base, lastTopupAt: daysAgo(91) }, NOW)).toBe(false)
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it("spend activity counts on its own (usage_logs MAX, not balance polls)", () => {
|
|
85
|
+
const base = { lifetimeTopupCredits: 100, lastTopupAt: daysAgo(200) }
|
|
86
|
+
expect(isPaygRetentionActive({ ...base, lastSpendAt: daysAgo(10) }, NOW)).toBe(true)
|
|
87
|
+
expect(isPaygRetentionActive({ ...base, lastSpendAt: daysAgo(91) }, NOW)).toBe(false)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it("either source alone is sufficient; the most recent wins", () => {
|
|
91
|
+
expect(
|
|
92
|
+
isPaygRetentionActive(
|
|
93
|
+
{ lifetimeTopupCredits: 100, lastTopupAt: daysAgo(120), lastSpendAt: daysAgo(5) },
|
|
94
|
+
NOW
|
|
95
|
+
)
|
|
96
|
+
).toBe(true)
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it("string timestamps (supabase rows) are accepted", () => {
|
|
100
|
+
expect(
|
|
101
|
+
isPaygRetentionActive(
|
|
102
|
+
{ lifetimeTopupCredits: 100, lastTopupAt: daysAgo(5).toISOString(), lastSpendAt: null },
|
|
103
|
+
NOW
|
|
104
|
+
)
|
|
105
|
+
).toBe(true)
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it("all-null activity is inactive; never-paid users are never retention-active", () => {
|
|
109
|
+
expect(
|
|
110
|
+
isPaygRetentionActive({ lifetimeTopupCredits: 100, lastTopupAt: null, lastSpendAt: null }, NOW)
|
|
111
|
+
).toBe(false)
|
|
112
|
+
expect(
|
|
113
|
+
isPaygRetentionActive({ lifetimeTopupCredits: 0, lastTopupAt: daysAgo(1), lastSpendAt: null }, NOW)
|
|
114
|
+
).toBe(false)
|
|
115
|
+
})
|
|
116
|
+
})
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Effective-tier resolution — the payg derivation.
|
|
3
|
+
*
|
|
4
|
+
* "payg" is a DERIVED tier, never stored: a user whose stored tier resolves
|
|
5
|
+
* to "free" but who has NET lifetime top-up credits (granted − refunded,
|
|
6
|
+
* clamped ≥ 0 by every SQL writer) is treated as "payg" by entitlement
|
|
7
|
+
* checks. Stored tier stays untouched, so subscription webhooks never learn
|
|
8
|
+
* about payg and a cancel→free transition re-derives it automatically.
|
|
9
|
+
*
|
|
10
|
+
* Lives in @nodaro/shared because the pipeline tier maps already do, and
|
|
11
|
+
* both backend (credit gates, workers) and read surfaces need one source.
|
|
12
|
+
*
|
|
13
|
+
* The profile fields are REQUIRED (non-optional) on purpose: a call site
|
|
14
|
+
* whose SELECT forgot to fetch `lifetime_topup_credits` becomes a compile
|
|
15
|
+
* error instead of a silent `?? 0` that quietly deactivates payg — that is
|
|
16
|
+
* the failure mode this shape exists to prevent.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** Stored-tier resolution — mirrors SQL COALESCE(tier, subscription_tier, 'free'). */
|
|
20
|
+
export function resolveStoredTier(p: {
|
|
21
|
+
tier: string | null
|
|
22
|
+
subscription_tier: string | null
|
|
23
|
+
}): string {
|
|
24
|
+
return p.tier ?? p.subscription_tier ?? "free"
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Effective tier: stored "free" with NET lifetime top-ups > 0 derives "payg".
|
|
29
|
+
* Every other stored tier passes through untouched.
|
|
30
|
+
*/
|
|
31
|
+
export function resolveEffectiveTier(p: {
|
|
32
|
+
tier: string | null
|
|
33
|
+
subscription_tier: string | null
|
|
34
|
+
lifetime_topup_credits: number
|
|
35
|
+
}): string {
|
|
36
|
+
const stored = resolveStoredTier(p)
|
|
37
|
+
if (stored === "free" && p.lifetime_topup_credits > 0) return "payg"
|
|
38
|
+
return stored
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Media-retention activity window for payg users (design 2026-07-05 §4.6). */
|
|
42
|
+
export const PAYG_RETENTION_DAYS = 90
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Is this payg user inside their retention-activity window?
|
|
46
|
+
*
|
|
47
|
+
* Activity = credit SPEND (callers supply MAX(usage_logs.created_at) — never
|
|
48
|
+
* `last_daily_reset`, which read-only balance polls bump) OR a top-up
|
|
49
|
+
* PURCHASE (`last_topup_at`). Within PAYG_RETENTION_DAYS of either → the
|
|
50
|
+
* nightly reaper leaves their media alone; past it, the standard free-tier
|
|
51
|
+
* reaper rules apply. A user with no lifetime purchases is never
|
|
52
|
+
* retention-active (they are not payg).
|
|
53
|
+
*/
|
|
54
|
+
export function isPaygRetentionActive(
|
|
55
|
+
p: {
|
|
56
|
+
lifetimeTopupCredits: number
|
|
57
|
+
lastTopupAt: string | Date | null
|
|
58
|
+
lastSpendAt: string | Date | null
|
|
59
|
+
},
|
|
60
|
+
now: Date
|
|
61
|
+
): boolean {
|
|
62
|
+
if (p.lifetimeTopupCredits <= 0) return false
|
|
63
|
+
const cutoff = now.getTime() - PAYG_RETENTION_DAYS * 24 * 60 * 60 * 1000
|
|
64
|
+
const at = (v: string | Date | null): number | null => {
|
|
65
|
+
if (v === null) return null
|
|
66
|
+
const t = v instanceof Date ? v.getTime() : new Date(v).getTime()
|
|
67
|
+
return Number.isFinite(t) ? t : null
|
|
68
|
+
}
|
|
69
|
+
const topup = at(p.lastTopupAt)
|
|
70
|
+
const spend = at(p.lastSpendAt)
|
|
71
|
+
return (topup !== null && topup >= cutoff) || (spend !== null && spend >= cutoff)
|
|
72
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -540,6 +540,13 @@ export type {
|
|
|
540
540
|
SharedListing,
|
|
541
541
|
} from "./community.js"
|
|
542
542
|
|
|
543
|
+
export {
|
|
544
|
+
resolveStoredTier,
|
|
545
|
+
resolveEffectiveTier,
|
|
546
|
+
isPaygRetentionActive,
|
|
547
|
+
PAYG_RETENTION_DAYS,
|
|
548
|
+
} from "./effective-tier.js"
|
|
549
|
+
|
|
543
550
|
export {
|
|
544
551
|
NODE_DEFAULT_TYPES,
|
|
545
552
|
validateProviderForNodeType,
|
package/src/model-constants.ts
CHANGED
|
@@ -605,8 +605,13 @@ export const IMAGE_I2I_PROVIDERS = [
|
|
|
605
605
|
"kontext-multi",
|
|
606
606
|
// BFL Flux 2 Pro — runs through Replicate with safety_tolerance=5 (max for Pro)
|
|
607
607
|
"flux-2-pro",
|
|
608
|
+
// BFL FLUX Fill Pro — dedicated masked inpainting via Replicate (white = edit area)
|
|
609
|
+
"flux-fill",
|
|
608
610
|
// BFL Flux 2 Max — runs through Replicate with safety_tolerance=5, up to 8 refs
|
|
609
611
|
"flux-2-max",
|
|
612
|
+
// BFL FLUX Fill Pro — dedicated inpainting via Replicate (image + mask + prompt,
|
|
613
|
+
// white = edit area). Second mask-capable i2i provider alongside ideogram-edit.
|
|
614
|
+
"flux-fill",
|
|
610
615
|
] as const
|
|
611
616
|
|
|
612
617
|
/** Image editing providers (upscale, remove bg, etc.) */
|
|
@@ -1039,7 +1044,7 @@ export type VoiceDesignModel = typeof VOICE_DESIGN_MODELS[number]
|
|
|
1039
1044
|
export const DEFAULT_VOICE_DESIGN_MODEL: VoiceDesignModel = "eleven_ttv_v3"
|
|
1040
1045
|
|
|
1041
1046
|
/** I2I providers that support mask-based inpainting */
|
|
1042
|
-
export const I2I_MASK_SUPPORT = new Set(["ideogram-edit"])
|
|
1047
|
+
export const I2I_MASK_SUPPORT = new Set(["ideogram-edit", "flux-fill"])
|
|
1043
1048
|
|
|
1044
1049
|
/**
|
|
1045
1050
|
* Mask edit tier per image-gen provider (single source of truth for inpaint).
|
package/src/pipeline-defaults.ts
CHANGED
|
@@ -65,6 +65,7 @@ export function validateModeActivation(
|
|
|
65
65
|
// Tier → max parallel pipelines (Architecture §5.4)
|
|
66
66
|
export const TIER_PIPELINE_PARALLELISM: Record<string, number> = {
|
|
67
67
|
free: 0,
|
|
68
|
+
payg: 1, // derived tier — pipeline entitlements copy basic's
|
|
68
69
|
basic: 1,
|
|
69
70
|
standard: 2,
|
|
70
71
|
pro: 3,
|
|
@@ -80,6 +81,7 @@ export const TIER_PIPELINE_PARALLELISM: Record<string, number> = {
|
|
|
80
81
|
// the user paid for, instead of the intended two-thirds.
|
|
81
82
|
export const TIER_MAX_PIPELINE_COST_CREDITS: Record<string, number> = {
|
|
82
83
|
free: 0,
|
|
84
|
+
payg: 3000, // derived tier — copies basic
|
|
83
85
|
basic: 3000,
|
|
84
86
|
standard: 8000,
|
|
85
87
|
pro: 20000,
|