@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nodaro/shared",
3
- "version": "2.3.0",
3
+ "version": "2.5.0",
4
4
  "description": "Shared types, model catalog, wire contracts, and structural vocabularies for the Nodaro platform and SDK.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -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,
@@ -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).
@@ -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,