@spark59/pricing 0.1.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.
@@ -0,0 +1,2 @@
1
+ export * from './pricing.js';
2
+ export * from './personas.js';
package/dist/index.js ADDED
@@ -0,0 +1,2 @@
1
+ export * from './pricing.js';
2
+ export * from './personas.js';
@@ -0,0 +1,27 @@
1
+ export type PersonaKey = 'accelerator' | 'school' | 'corporate';
2
+ export interface PersonaInput {
3
+ key: string;
4
+ label: string;
5
+ help?: string;
6
+ default: number;
7
+ min: number;
8
+ max: number;
9
+ step: number;
10
+ }
11
+ export type Inputs = Record<string, number>;
12
+ export interface Persona {
13
+ key: PersonaKey;
14
+ title: string;
15
+ ctaLabel: string;
16
+ ctaSubject: string;
17
+ nfpDefault: boolean;
18
+ inputs: PersonaInput[];
19
+ formula: (i: Inputs) => number;
20
+ unitMetric: {
21
+ numeratorFn?: (i: Inputs, annual: number) => number;
22
+ denominatorFn: (i: Inputs) => number;
23
+ unitLabel: string;
24
+ };
25
+ summaryFn: (i: Inputs, annual: number) => string[];
26
+ }
27
+ export declare const PERSONAS: Record<PersonaKey, Persona>;
@@ -0,0 +1,95 @@
1
+ // Persona configs for the shared pricing calculator.
2
+ //
3
+ // Pricing is internally accounted in founder-months (single source of truth in
4
+ // pricing.ts). Each persona declares how to *display* that pricing in its
5
+ // buyer's native language: a primary $/unit metric, a "what this covers"
6
+ // summary, and input labels that match how the buyer talks about their program.
7
+ // - Accelerator: applicants, cohorts, founders → $ per founder coached
8
+ // - University: students × classes → $ per student per class
9
+ // - Corporate: seats × engagement → $ per seat per month
10
+ const fmt = (n) => '$' + Math.round(n).toLocaleString('en-US');
11
+ // Accelerator math helpers — used by both the unit metric numerator and the
12
+ // summary breakdown, so the two stay consistent.
13
+ function acceleratorScreenFm(i) { return i.applicants; }
14
+ function acceleratorCoachFm(i) { return i.foundersCohort * i.cohorts * (1 + i.programMonths); }
15
+ function acceleratorShare(i, annual) {
16
+ const screenFm = acceleratorScreenFm(i);
17
+ const coachFm = acceleratorCoachFm(i);
18
+ const totalFm = screenFm + coachFm;
19
+ const screenShare = totalFm > 0 ? Math.round(annual * (screenFm / totalFm)) : 0;
20
+ return { screenFm, coachFm, screenShare, coachShare: annual - screenShare };
21
+ }
22
+ export const PERSONAS = {
23
+ accelerator: {
24
+ key: 'accelerator',
25
+ title: 'Accelerator / Incubator',
26
+ ctaLabel: 'Schedule a scoping call',
27
+ ctaSubject: 'LEANSpark for Accelerators',
28
+ nfpDefault: false,
29
+ inputs: [
30
+ { key: 'applicants', label: 'Applicants screened per year with <span class="calc-tooltip" tabindex="0" data-tooltip="Business Model Design playbooks — guided exercises (Lean Canvas, customer factory, problem interviews) that turn raw applications into structured, comparable signals so reviewers can screen at depth, not by gut.">BMD</span> playbook', default: 100, min: 0, max: 2000, step: 25 },
31
+ { key: 'foundersCohort', label: 'Founders per cohort', default: 10, min: 1, max: 200, step: 1 },
32
+ { key: 'cohorts', label: 'Cohorts per year', default: 1, min: 1, max: 12, step: 1 },
33
+ { key: 'programMonths', label: 'Program length (months)', default: 4, min: 1, max: 12, step: 1 },
34
+ { key: 'coachSeats', label: 'Certified Coach seats <span class="calc-tooltip" tabindex="0" data-tooltip="$99/seat/month — coaching certification, workshop replays, playbooks, and the cohort dashboard. Billed for the program length. Free $0 management seats and the $199 Coach Agent tier are quoted separately.">($99/mo)</span>', default: 0, min: 0, max: 100, step: 1 },
35
+ ],
36
+ formula: (i) => acceleratorScreenFm(i) + acceleratorCoachFm(i),
37
+ unitMetric: {
38
+ // Numerator = coaching share only; denominator = founders coached.
39
+ // Applicant funnel is surfaced separately in the summary, not blended
40
+ // into the $/founder headline.
41
+ numeratorFn: (i, annual) => acceleratorShare(i, annual).coachShare,
42
+ denominatorFn: (i) => Math.max(1, i.foundersCohort * i.cohorts),
43
+ unitLabel: 'founder coached',
44
+ },
45
+ summaryFn: (i, annual) => {
46
+ const { screenShare, coachShare } = acceleratorShare(i, annual);
47
+ return [
48
+ i.applicants > 0 ? `${i.applicants} applicants screened — ~${fmt(screenShare)}` : null,
49
+ `${i.foundersCohort * i.cohorts} founders × ${i.cohorts} cohort${i.cohorts === 1 ? '' : 's'} × ${i.programMonths} months — ~${fmt(coachShare)}`,
50
+ 'Re-allocate credits across cohorts mid-program',
51
+ ].filter(Boolean);
52
+ },
53
+ },
54
+ school: {
55
+ key: 'school',
56
+ title: 'Schools & Universities',
57
+ ctaLabel: 'Talk to our education team',
58
+ ctaSubject: 'LEANSpark for Schools and Universities',
59
+ nfpDefault: true,
60
+ inputs: [
61
+ { key: 'studentsPerTerm', label: 'Students per class', default: 25, min: 1, max: 2000, step: 5 },
62
+ { key: 'terms', label: 'Classes per year', default: 2, min: 1, max: 12, step: 1 },
63
+ { key: 'coachSeats', label: 'Certified Coach seats <span class="calc-tooltip" tabindex="0" data-tooltip="$99/seat/month — coaching certification, workshop replays, playbooks, and the cohort dashboard. Billed annually. Free $0 management seats and the $199 Coach Agent tier are quoted separately.">($99/mo)</span>', default: 0, min: 0, max: 100, step: 1 },
64
+ ],
65
+ formula: (i) => i.studentsPerTerm * i.terms,
66
+ unitMetric: {
67
+ denominatorFn: (i) => Math.max(1, i.studentsPerTerm * i.terms),
68
+ unitLabel: 'student per class',
69
+ },
70
+ summaryFn: (i) => [
71
+ `${i.studentsPerTerm} students × ${i.terms} class${i.terms === 1 ? '' : 'es'} per year`,
72
+ '500 credits per student (BMD playbook) — admins can raise caps for advanced students',
73
+ ],
74
+ },
75
+ corporate: {
76
+ key: 'corporate',
77
+ title: 'Corporate Innovation',
78
+ ctaLabel: 'Book an innovation-program call',
79
+ ctaSubject: 'LEANSpark for Corporate Innovation',
80
+ nfpDefault: false,
81
+ inputs: [
82
+ { key: 'seats', label: 'Number of seats', help: 'One seat = one innovator with full BMD + validation access. Fair-use cap of 500 credits/month; admins can re-allocate to power users.', default: 10, min: 1, max: 1000, step: 1 },
83
+ ],
84
+ formula: (i) => i.seats * 12,
85
+ unitMetric: {
86
+ denominatorFn: (i) => Math.max(1, i.seats * 12),
87
+ unitLabel: 'seat per month',
88
+ },
89
+ summaryFn: (i) => [
90
+ `${i.seats} seat${i.seats === 1 ? '' : 's'} × 12-month annual contract`,
91
+ 'SSO, admin console, and cross-BU allocation reporting',
92
+ 'Fair-use credit pool — admins can raise per-seat caps for power users',
93
+ ],
94
+ },
95
+ };
@@ -0,0 +1,149 @@
1
+ export interface PricingTier {
2
+ name: string;
3
+ minFm: number;
4
+ maxFm: number | null;
5
+ rate: number;
6
+ nfpRate: number;
7
+ }
8
+ export declare const MARGIN_FLOOR_PER_FM = 25;
9
+ export declare const PRICING: {
10
+ currency: string;
11
+ creditsPerFounderMonth: number;
12
+ retailPerCredit: number;
13
+ tiers: PricingTier[];
14
+ activities: {
15
+ bmd: {
16
+ fm: number;
17
+ label: string;
18
+ };
19
+ validationMonth: {
20
+ fm: number;
21
+ label: string;
22
+ };
23
+ applicationScreen: {
24
+ fm: number;
25
+ label: string;
26
+ };
27
+ };
28
+ validityMonths: {
29
+ default: number;
30
+ scale: number;
31
+ };
32
+ };
33
+ export type AppliedDiscount = 'none' | 'nfp' | 'ppp';
34
+ export interface Quote {
35
+ founderMonths: number;
36
+ tier: string;
37
+ rate: number;
38
+ baseAnnual: number;
39
+ annual: number;
40
+ retailAnnual: number;
41
+ savings: number;
42
+ appliedDiscount: AppliedDiscount;
43
+ pppDiscountNominal: number | null;
44
+ pppDiscountEffective: number | null;
45
+ floorActive: boolean;
46
+ perFmRate: number;
47
+ }
48
+ export declare const RETAIL_PER_FM: number;
49
+ export interface QuoteOptions {
50
+ nfp?: boolean;
51
+ pppDiscount?: number | null;
52
+ marginFloorPerFm?: number;
53
+ }
54
+ export declare function quote(fm: number, opts?: QuoteOptions): Quote;
55
+ export declare const ACADEMY_CERT_ONETIME = 0;
56
+ export declare const ACADEMY_RECURRING_MONTHLY = 99;
57
+ export type AcademyWaive = 'none' | 'cert' | 'all';
58
+ export interface AcademyLine {
59
+ seats: number;
60
+ months: number;
61
+ nominalCents: number;
62
+ waive: AcademyWaive;
63
+ amountCents: number;
64
+ amountLabel: string | null;
65
+ }
66
+ export declare function academyLine(coachSeats: number, programMonths: number, opts?: {
67
+ waive?: AcademyWaive;
68
+ }): AcademyLine;
69
+ export interface OrgQuote extends Quote {
70
+ coach: {
71
+ monthly: number;
72
+ months: number;
73
+ cost: number;
74
+ };
75
+ grandTotal: number;
76
+ }
77
+ export declare function orgQuote(fm: number, opts?: QuoteOptions & {
78
+ coachSeats?: number;
79
+ coachMonths?: number;
80
+ }): OrgQuote;
81
+ export declare function formatCurrency(n: number): string;
82
+ export declare const COACH_CREDIT_MONTHLY = 115;
83
+ export interface CoachCreditLine {
84
+ coachMonths: number;
85
+ monthly: number;
86
+ amountCents: number;
87
+ credits: number;
88
+ }
89
+ export declare function coachCreditLine(coachSeats: number, programMonths: number): CoachCreditLine;
90
+ export interface BalanceTier {
91
+ seatsPaid: number;
92
+ fmPerSeat: number;
93
+ used: number;
94
+ }
95
+ export interface BalanceCredit {
96
+ unusedSeats: number;
97
+ unusedFm: number;
98
+ paidFm: number;
99
+ pct: number;
100
+ amountCents: number;
101
+ label: string;
102
+ }
103
+ export declare function balanceCredit(input: {
104
+ tiers: BalanceTier[];
105
+ prepaidUsd: number;
106
+ }): BalanceCredit;
107
+ export interface CohortPhase {
108
+ label: string;
109
+ seats: number;
110
+ months: number;
111
+ }
112
+ export interface FounderPhaseLine {
113
+ label: string;
114
+ fm: number;
115
+ credits: number;
116
+ amountCents: number;
117
+ rateLabel: string;
118
+ spansTier: boolean;
119
+ }
120
+ export declare function allocateFounderPhases(phases: CohortPhase[]): FounderPhaseLine[];
121
+ export type DealLine = {
122
+ key: string;
123
+ label: string;
124
+ qty?: number;
125
+ qtyLabel?: string;
126
+ rateLabel?: string;
127
+ credits?: number;
128
+ amountCents: number;
129
+ amountLabel?: string | null;
130
+ };
131
+ export interface DealQuote {
132
+ lines: DealLine[];
133
+ subtotalCents: number;
134
+ amountCents: number;
135
+ poolCredits: number;
136
+ }
137
+ export interface DealQuoteInput {
138
+ phases: CohortPhase[];
139
+ coachSeats: number;
140
+ programMonths: number;
141
+ academyWaive?: AcademyWaive;
142
+ adjustments?: {
143
+ label: string;
144
+ amountCents: number;
145
+ credits?: number;
146
+ }[];
147
+ bufferCredits?: number;
148
+ }
149
+ export declare function dealQuote(input: DealQuoteInput): DealQuote;
@@ -0,0 +1,226 @@
1
+ // Shared pricing model for LEANSpark organization plans.
2
+ // Single source of truth for rate cards, calculators, and quote logic.
3
+ // Policy constant: minimum revenue per founder-month required to keep ~80%
4
+ // gross margin at current Claude API + Stripe-fee cost mix (~$5/fm). The
5
+ // margin floor caps how deep PPP can discount any individual quote. Raise to
6
+ // $30 if API pricing rises ~50%. Reviewed quarterly.
7
+ export const MARGIN_FLOOR_PER_FM = 25;
8
+ export const PRICING = {
9
+ currency: 'USD',
10
+ creditsPerFounderMonth: 500,
11
+ retailPerCredit: 0.25,
12
+ tiers: [
13
+ // Marginal rates (each rate applies only to fm within that tier).
14
+ // Calibrated so effective prices match the prior flat-tier model at the
15
+ // two most-trafficked anchors (100 fm and 500 fm); higher volumes drift
16
+ // monotonically — slightly higher at 1,000 fm, materially lower at 2,000+.
17
+ { name: 'pilot', minFm: 0, maxFm: 99, rate: 115, nfpRate: 95 },
18
+ { name: 'studio', minFm: 100, maxFm: 499, rate: 90, nfpRate: 70 },
19
+ { name: 'growth', minFm: 500, maxFm: 999, rate: 50, nfpRate: 35 },
20
+ { name: 'scale', minFm: 1000, maxFm: 1999, rate: 40, nfpRate: 30 },
21
+ { name: 'enterprise', minFm: 2000, maxFm: null, rate: 35, nfpRate: 25 },
22
+ ],
23
+ activities: {
24
+ bmd: { fm: 1, label: 'Business model design (one-time)' },
25
+ validationMonth: { fm: 1, label: 'Validation (per founder-month)' },
26
+ applicationScreen: { fm: 1, label: 'Applicant screening (full BMD)' },
27
+ },
28
+ validityMonths: { default: 12, scale: 18 },
29
+ };
30
+ // True retail anchor for the "below retail" savings line: the individual-founder
31
+ // per-credit rate × credits-per-fm. This is the price a single founder pays
32
+ // for the same fm of usage outside an org contract. Org tiers (including pilot)
33
+ // always sit below this, so savings are non-zero even at the smallest buy.
34
+ export const RETAIL_PER_FM = PRICING.retailPerCredit * PRICING.creditsPerFounderMonth;
35
+ function marginalAnnual(fm, nfp) {
36
+ let annual = 0;
37
+ let topTier = PRICING.tiers[0];
38
+ for (const tier of PRICING.tiers) {
39
+ const tierMin = tier.minFm;
40
+ const tierMaxExclusive = tier.maxFm === null ? Infinity : tier.maxFm + 1;
41
+ const fmInTier = Math.max(0, Math.min(fm, tierMaxExclusive) - tierMin);
42
+ if (fmInTier > 0) {
43
+ annual += fmInTier * (nfp ? tier.nfpRate : tier.rate);
44
+ topTier = tier;
45
+ }
46
+ }
47
+ return { annual, topTier };
48
+ }
49
+ // Marginal pricing: each tier rate applies only to the founder-months *within*
50
+ // that tier, like income-tax brackets. Eliminates pricing cliffs at tier
51
+ // boundaries where flat-tier pricing could cause "buy more, pay less" inversions.
52
+ //
53
+ // NFP and PPP are evaluated separately and the cheaper of the two wins —
54
+ // they don't stack. The margin floor caps how deep any discount can cut.
55
+ export function quote(fm, opts = {}) {
56
+ const safeFm = Math.max(0, Math.round(fm));
57
+ const floor = opts.marginFloorPerFm ?? MARGIN_FLOOR_PER_FM;
58
+ const floorAnnual = safeFm * floor;
59
+ const fp = marginalAnnual(safeFm, false);
60
+ const baseAnnual = fp.annual;
61
+ const candidates = [
62
+ { kind: 'none', annual: baseAnnual, topTier: fp.topTier },
63
+ ];
64
+ if (opts.nfp) {
65
+ const nfp = marginalAnnual(safeFm, true);
66
+ candidates.push({ kind: 'nfp', annual: Math.max(floorAnnual, nfp.annual), topTier: nfp.topTier });
67
+ }
68
+ let pppDiscountEffective = null;
69
+ let floorActive = false;
70
+ if (opts.pppDiscount && opts.pppDiscount > 0) {
71
+ const pppRaw = baseAnnual * (1 - opts.pppDiscount / 100);
72
+ const pppCapped = Math.max(floorAnnual, pppRaw);
73
+ floorActive = pppCapped > pppRaw;
74
+ pppDiscountEffective = baseAnnual > 0
75
+ ? Math.round((1 - pppCapped / baseAnnual) * 100)
76
+ : 0;
77
+ candidates.push({ kind: 'ppp', annual: pppCapped, topTier: fp.topTier });
78
+ }
79
+ // Pick the lowest-priced candidate; ties break toward earlier entries which
80
+ // means "none" beats "nfp" beats "ppp" — preserving the simplest narrative
81
+ // when discounts don't actually save anything.
82
+ const winner = candidates.reduce((best, c) => (c.annual < best.annual ? c : best), candidates[0]);
83
+ const topRate = winner.kind === 'nfp' ? winner.topTier.nfpRate : winner.topTier.rate;
84
+ const retailAnnual = safeFm * RETAIL_PER_FM;
85
+ const annual = Math.round(winner.annual);
86
+ return {
87
+ founderMonths: safeFm,
88
+ tier: winner.topTier.name,
89
+ rate: topRate,
90
+ baseAnnual: Math.round(baseAnnual),
91
+ annual,
92
+ retailAnnual: Math.round(retailAnnual),
93
+ savings: Math.max(0, Math.round(retailAnnual - annual)),
94
+ appliedDiscount: winner.kind,
95
+ pppDiscountNominal: opts.pppDiscount && opts.pppDiscount > 0 ? opts.pppDiscount : null,
96
+ pppDiscountEffective: winner.kind === 'ppp' ? pppDiscountEffective : null,
97
+ floorActive: winner.kind === 'ppp' && floorActive,
98
+ perFmRate: safeFm > 0 ? annual / safeFm : 0,
99
+ };
100
+ }
101
+ // ---------- Academy seat (cert + recurring, with waive modes) ----------
102
+ // Org deals add Certified Coach (Academy) seats on top of founder-months.
103
+ // The seat splits into a one-time certification fee and a recurring monthly
104
+ // fee, both per seat. Waive modes cover grandfathered cohorts (cert waived,
105
+ // recurring still billed) and legacy sponsored cohorts like NEC-X (both
106
+ // waived, $0, but the nominal value is preserved for reporting/quotes).
107
+ export const ACADEMY_CERT_ONETIME = 0; // per seat, one-time — REQUIRED: set to Ash's value
108
+ export const ACADEMY_RECURRING_MONTHLY = 99; // per seat/month — REQUIRED: set to Ash's value
109
+ export function academyLine(coachSeats, programMonths, opts = {}) {
110
+ const seats = Math.max(0, Math.round(coachSeats));
111
+ const months = Math.max(0, Math.round(programMonths));
112
+ const waive = opts.waive ?? 'none';
113
+ const certCents = seats * ACADEMY_CERT_ONETIME * 100;
114
+ const recurCents = seats * ACADEMY_RECURRING_MONTHLY * months * 100;
115
+ const nominalCents = certCents + recurCents;
116
+ const amountCents = waive === 'all' ? 0 : waive === 'cert' ? recurCents : nominalCents;
117
+ const amountLabel = waive === 'all' ? 'waived' : null;
118
+ return { seats, months, nominalCents, waive, amountCents, amountLabel };
119
+ }
120
+ export function orgQuote(fm, opts = {}) {
121
+ const base = quote(fm, opts);
122
+ // Recurring-only: the public calculator's "N × $99/mo × Mmo" line must never
123
+ // absorb the one-time certification fee (ACADEMY_CERT_ONETIME). If the cert
124
+ // fee is later set > 0, deriving coach.cost from academyLine(...,{waive:'none'})
125
+ // would silently inflate this monthly figure while the label still reads
126
+ // "$99/mo" — so compute the recurring portion directly instead.
127
+ const seats = Math.max(0, Math.round(opts.coachSeats ?? 0));
128
+ const months = Math.max(1, Math.round(opts.coachMonths ?? 12));
129
+ const cost = seats * ACADEMY_RECURRING_MONTHLY * months; // recurring only, dollars — cert excluded from the monthly public view
130
+ const coach = { monthly: ACADEMY_RECURRING_MONTHLY, months, cost };
131
+ return { ...base, coach, grandTotal: base.annual + cost };
132
+ }
133
+ export function formatCurrency(n) {
134
+ return '$' + n.toLocaleString('en-US');
135
+ }
136
+ export const COACH_CREDIT_MONTHLY = 115;
137
+ export function coachCreditLine(coachSeats, programMonths) {
138
+ const seats = Math.max(0, Math.round(coachSeats));
139
+ const months = Math.max(0, Math.round(programMonths));
140
+ const coachMonths = seats * months;
141
+ return {
142
+ coachMonths,
143
+ monthly: COACH_CREDIT_MONTHLY,
144
+ amountCents: coachMonths * COACH_CREDIT_MONTHLY * 100,
145
+ credits: coachMonths * PRICING.creditsPerFounderMonth,
146
+ };
147
+ }
148
+ export function balanceCredit(input) {
149
+ let unusedSeats = 0, unusedFm = 0, paidFm = 0;
150
+ for (const t of input.tiers) {
151
+ const unused = Math.max(0, t.seatsPaid - t.used);
152
+ unusedSeats += unused;
153
+ unusedFm += unused * t.fmPerSeat;
154
+ paidFm += t.seatsPaid * t.fmPerSeat;
155
+ }
156
+ const ratio = paidFm > 0 ? unusedFm / paidFm : 0;
157
+ const pct = ratio * 100;
158
+ // Guard against a signed zero (-0) leaking into the UI as "-$0" when ratio is 0.
159
+ const amountCents = ratio > 0 ? -Math.round(input.prepaidUsd * ratio * 100) : 0;
160
+ const label = `${unusedFm} unused founder-months (${(Math.round(pct * 100) / 100)}%)`;
161
+ return { unusedSeats, unusedFm, paidFm, pct, amountCents, label };
162
+ }
163
+ // Marginal cost of the fm interval [from, from+len) walked up the for-profit ladder.
164
+ function marginalInterval(from, len) {
165
+ let cents = 0;
166
+ const tiers = new Set();
167
+ for (const t of PRICING.tiers) {
168
+ const lo = Math.max(from, t.minFm);
169
+ const hi = Math.min(from + len, t.maxFm === null ? Infinity : t.maxFm + 1);
170
+ const span = Math.max(0, hi - lo);
171
+ if (span > 0) {
172
+ cents += span * t.rate * 100;
173
+ tiers.add(t.name);
174
+ }
175
+ }
176
+ return { cents, tiers };
177
+ }
178
+ // For-profit marginal rates only. NFP/PPP are not applied to cohort-phase pricing (deferred follow-up).
179
+ export function allocateFounderPhases(phases) {
180
+ let cursor = 0;
181
+ return phases.map(p => {
182
+ const fm = Math.max(0, Math.round(p.seats * p.months));
183
+ const { cents, tiers } = marginalInterval(cursor, fm);
184
+ cursor += fm;
185
+ const spansTier = tiers.size > 1;
186
+ const blendedPerMo = fm > 0 ? Math.round(cents / 100 / fm) : 0;
187
+ return {
188
+ label: p.label, fm,
189
+ credits: fm * PRICING.creditsPerFounderMonth,
190
+ amountCents: cents,
191
+ rateLabel: spansTier ? `~$${blendedPerMo}/founder-mo (blended)` : `$${blendedPerMo}/founder-mo`,
192
+ spansTier,
193
+ };
194
+ });
195
+ }
196
+ // For-profit marginal rates only. NFP/PPP are not applied to cohort-phase pricing (deferred follow-up).
197
+ export function dealQuote(input) {
198
+ const lines = [];
199
+ const founderRows = allocateFounderPhases(input.phases);
200
+ founderRows.forEach((r, i) => {
201
+ lines.push({ key: `founder-${i}`, label: r.label, qty: r.fm, qtyLabel: 'founder-months',
202
+ rateLabel: r.rateLabel, credits: r.credits, amountCents: r.amountCents });
203
+ });
204
+ const coach = coachCreditLine(input.coachSeats, input.programMonths);
205
+ if (coach.coachMonths > 0) {
206
+ lines.push({ key: 'coach', label: 'Coach seats — Ask + multiplayer',
207
+ qty: coach.coachMonths, qtyLabel: 'coach-months', rateLabel: `$${COACH_CREDIT_MONTHLY}/coach-mo`,
208
+ credits: coach.credits, amountCents: coach.amountCents });
209
+ }
210
+ const academy = academyLine(input.coachSeats, input.programMonths, { waive: input.academyWaive });
211
+ if (academy.seats > 0) {
212
+ lines.push({ key: 'academy', label: 'Certified Coach — Academy + certification',
213
+ qty: academy.seats, qtyLabel: 'seats', amountCents: academy.amountCents,
214
+ amountLabel: academy.amountLabel });
215
+ }
216
+ const subtotalCents = lines.reduce((s, l) => s + l.amountCents, 0);
217
+ let adjCredits = 0;
218
+ (input.adjustments ?? []).forEach((a, i) => {
219
+ lines.push({ key: `adjustment-${i}`, label: a.label, amountCents: a.amountCents, credits: a.credits });
220
+ adjCredits += a.credits ?? 0;
221
+ });
222
+ const founderCoachCredits = founderRows.reduce((s, r) => s + r.credits, 0) + coach.credits;
223
+ const poolCredits = founderCoachCredits + adjCredits + (input.bufferCredits ?? 0);
224
+ const amountCents = lines.reduce((s, l) => s + l.amountCents, 0);
225
+ return { lines, subtotalCents, amountCents, poolCredits };
226
+ }
package/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "@spark59/pricing",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "exports": {
6
+ ".": {
7
+ "types": "./dist/index.d.ts",
8
+ "import": "./dist/index.js"
9
+ },
10
+ "./src": "./src/index.ts"
11
+ },
12
+ "files": [
13
+ "dist",
14
+ "src"
15
+ ],
16
+ "scripts": {
17
+ "prepare": "tsc -p tsconfig.json",
18
+ "build": "tsc -p tsconfig.json",
19
+ "test": "vitest run",
20
+ "test:watch": "vitest"
21
+ },
22
+ "devDependencies": {
23
+ "typescript": "^5.6.0",
24
+ "vitest": "^2.1.0",
25
+ "jsdom": "^25.0.0"
26
+ },
27
+ "publishConfig": {
28
+ "access": "public"
29
+ }
30
+ }
package/src/index.ts ADDED
@@ -0,0 +1,2 @@
1
+ export * from './pricing.js';
2
+ export * from './personas.js';
@@ -0,0 +1,133 @@
1
+ // Persona configs for the shared pricing calculator.
2
+ //
3
+ // Pricing is internally accounted in founder-months (single source of truth in
4
+ // pricing.ts). Each persona declares how to *display* that pricing in its
5
+ // buyer's native language: a primary $/unit metric, a "what this covers"
6
+ // summary, and input labels that match how the buyer talks about their program.
7
+ // - Accelerator: applicants, cohorts, founders → $ per founder coached
8
+ // - University: students × classes → $ per student per class
9
+ // - Corporate: seats × engagement → $ per seat per month
10
+
11
+ export type PersonaKey = 'accelerator' | 'school' | 'corporate';
12
+
13
+ export interface PersonaInput {
14
+ key: string;
15
+ label: string;
16
+ help?: string;
17
+ default: number;
18
+ min: number;
19
+ max: number;
20
+ step: number;
21
+ }
22
+
23
+ export type Inputs = Record<string, number>;
24
+
25
+ export interface Persona {
26
+ key: PersonaKey;
27
+ title: string;
28
+ ctaLabel: string;
29
+ ctaSubject: string;
30
+ nfpDefault: boolean;
31
+ inputs: PersonaInput[];
32
+ // Inputs → total founder-months (the internal accounting unit).
33
+ formula: (i: Inputs) => number;
34
+ // How to display the headline $/unit. `numeratorFn` defaults to the full
35
+ // annual price; override it when the relevant cost isn't the whole bill
36
+ // (e.g. accelerator: show $ per founder *coached*, excluding applicant funnel).
37
+ unitMetric: {
38
+ numeratorFn?: (i: Inputs, annual: number) => number;
39
+ denominatorFn: (i: Inputs) => number;
40
+ unitLabel: string; // singular, e.g. "founder coached", "student per class"
41
+ };
42
+ // Inputs + annual → "what this covers" bullet points.
43
+ summaryFn: (i: Inputs, annual: number) => string[];
44
+ }
45
+
46
+ const fmt = (n: number) => '$' + Math.round(n).toLocaleString('en-US');
47
+
48
+ // Accelerator math helpers — used by both the unit metric numerator and the
49
+ // summary breakdown, so the two stay consistent.
50
+ function acceleratorScreenFm(i: Inputs) { return i.applicants; }
51
+ function acceleratorCoachFm(i: Inputs) { return i.foundersCohort * i.cohorts * (1 + i.programMonths); }
52
+ function acceleratorShare(i: Inputs, annual: number) {
53
+ const screenFm = acceleratorScreenFm(i);
54
+ const coachFm = acceleratorCoachFm(i);
55
+ const totalFm = screenFm + coachFm;
56
+ const screenShare = totalFm > 0 ? Math.round(annual * (screenFm / totalFm)) : 0;
57
+ return { screenFm, coachFm, screenShare, coachShare: annual - screenShare };
58
+ }
59
+
60
+ export const PERSONAS: Record<PersonaKey, Persona> = {
61
+ accelerator: {
62
+ key: 'accelerator',
63
+ title: 'Accelerator / Incubator',
64
+ ctaLabel: 'Schedule a scoping call',
65
+ ctaSubject: 'LEANSpark for Accelerators',
66
+ nfpDefault: false,
67
+ inputs: [
68
+ { key: 'applicants', label: 'Applicants screened per year with <span class="calc-tooltip" tabindex="0" data-tooltip="Business Model Design playbooks — guided exercises (Lean Canvas, customer factory, problem interviews) that turn raw applications into structured, comparable signals so reviewers can screen at depth, not by gut.">BMD</span> playbook', default: 100, min: 0, max: 2000, step: 25 },
69
+ { key: 'foundersCohort', label: 'Founders per cohort', default: 10, min: 1, max: 200, step: 1 },
70
+ { key: 'cohorts', label: 'Cohorts per year', default: 1, min: 1, max: 12, step: 1 },
71
+ { key: 'programMonths', label: 'Program length (months)', default: 4, min: 1, max: 12, step: 1 },
72
+ { key: 'coachSeats', label: 'Certified Coach seats <span class="calc-tooltip" tabindex="0" data-tooltip="$99/seat/month — coaching certification, workshop replays, playbooks, and the cohort dashboard. Billed for the program length. Free $0 management seats and the $199 Coach Agent tier are quoted separately.">($99/mo)</span>', default: 0, min: 0, max: 100, step: 1 },
73
+ ],
74
+ formula: (i) => acceleratorScreenFm(i) + acceleratorCoachFm(i),
75
+ unitMetric: {
76
+ // Numerator = coaching share only; denominator = founders coached.
77
+ // Applicant funnel is surfaced separately in the summary, not blended
78
+ // into the $/founder headline.
79
+ numeratorFn: (i, annual) => acceleratorShare(i, annual).coachShare,
80
+ denominatorFn: (i) => Math.max(1, i.foundersCohort * i.cohorts),
81
+ unitLabel: 'founder coached',
82
+ },
83
+ summaryFn: (i, annual) => {
84
+ const { screenShare, coachShare } = acceleratorShare(i, annual);
85
+ return [
86
+ i.applicants > 0 ? `${i.applicants} applicants screened — ~${fmt(screenShare)}` : null,
87
+ `${i.foundersCohort * i.cohorts} founders × ${i.cohorts} cohort${i.cohorts === 1 ? '' : 's'} × ${i.programMonths} months — ~${fmt(coachShare)}`,
88
+ 'Re-allocate credits across cohorts mid-program',
89
+ ].filter(Boolean) as string[];
90
+ },
91
+ },
92
+ school: {
93
+ key: 'school',
94
+ title: 'Schools & Universities',
95
+ ctaLabel: 'Talk to our education team',
96
+ ctaSubject: 'LEANSpark for Schools and Universities',
97
+ nfpDefault: true,
98
+ inputs: [
99
+ { key: 'studentsPerTerm', label: 'Students per class', default: 25, min: 1, max: 2000, step: 5 },
100
+ { key: 'terms', label: 'Classes per year', default: 2, min: 1, max: 12, step: 1 },
101
+ { key: 'coachSeats', label: 'Certified Coach seats <span class="calc-tooltip" tabindex="0" data-tooltip="$99/seat/month — coaching certification, workshop replays, playbooks, and the cohort dashboard. Billed annually. Free $0 management seats and the $199 Coach Agent tier are quoted separately.">($99/mo)</span>', default: 0, min: 0, max: 100, step: 1 },
102
+ ],
103
+ formula: (i) => i.studentsPerTerm * i.terms,
104
+ unitMetric: {
105
+ denominatorFn: (i) => Math.max(1, i.studentsPerTerm * i.terms),
106
+ unitLabel: 'student per class',
107
+ },
108
+ summaryFn: (i) => [
109
+ `${i.studentsPerTerm} students × ${i.terms} class${i.terms === 1 ? '' : 'es'} per year`,
110
+ '500 credits per student (BMD playbook) — admins can raise caps for advanced students',
111
+ ],
112
+ },
113
+ corporate: {
114
+ key: 'corporate',
115
+ title: 'Corporate Innovation',
116
+ ctaLabel: 'Book an innovation-program call',
117
+ ctaSubject: 'LEANSpark for Corporate Innovation',
118
+ nfpDefault: false,
119
+ inputs: [
120
+ { key: 'seats', label: 'Number of seats', help: 'One seat = one innovator with full BMD + validation access. Fair-use cap of 500 credits/month; admins can re-allocate to power users.', default: 10, min: 1, max: 1000, step: 1 },
121
+ ],
122
+ formula: (i) => i.seats * 12,
123
+ unitMetric: {
124
+ denominatorFn: (i) => Math.max(1, i.seats * 12),
125
+ unitLabel: 'seat per month',
126
+ },
127
+ summaryFn: (i) => [
128
+ `${i.seats} seat${i.seats === 1 ? '' : 's'} × 12-month annual contract`,
129
+ 'SSO, admin console, and cross-BU allocation reporting',
130
+ 'Fair-use credit pool — admins can raise per-seat caps for power users',
131
+ ],
132
+ },
133
+ };
package/src/pricing.ts ADDED
@@ -0,0 +1,372 @@
1
+ // Shared pricing model for LEANSpark organization plans.
2
+ // Single source of truth for rate cards, calculators, and quote logic.
3
+
4
+ export interface PricingTier {
5
+ name: string;
6
+ minFm: number;
7
+ maxFm: number | null; // null = unbounded
8
+ rate: number; // $/founder-month for-profit
9
+ nfpRate: number; // $/founder-month verified non-profit
10
+ }
11
+
12
+ // Policy constant: minimum revenue per founder-month required to keep ~80%
13
+ // gross margin at current Claude API + Stripe-fee cost mix (~$5/fm). The
14
+ // margin floor caps how deep PPP can discount any individual quote. Raise to
15
+ // $30 if API pricing rises ~50%. Reviewed quarterly.
16
+ export const MARGIN_FLOOR_PER_FM = 25;
17
+
18
+ export const PRICING = {
19
+ currency: 'USD',
20
+ creditsPerFounderMonth: 500,
21
+ retailPerCredit: 0.25,
22
+ tiers: [
23
+ // Marginal rates (each rate applies only to fm within that tier).
24
+ // Calibrated so effective prices match the prior flat-tier model at the
25
+ // two most-trafficked anchors (100 fm and 500 fm); higher volumes drift
26
+ // monotonically — slightly higher at 1,000 fm, materially lower at 2,000+.
27
+ { name: 'pilot', minFm: 0, maxFm: 99, rate: 115, nfpRate: 95 },
28
+ { name: 'studio', minFm: 100, maxFm: 499, rate: 90, nfpRate: 70 },
29
+ { name: 'growth', minFm: 500, maxFm: 999, rate: 50, nfpRate: 35 },
30
+ { name: 'scale', minFm: 1000, maxFm: 1999, rate: 40, nfpRate: 30 },
31
+ { name: 'enterprise', minFm: 2000, maxFm: null, rate: 35, nfpRate: 25 },
32
+ ] as PricingTier[],
33
+ activities: {
34
+ bmd: { fm: 1, label: 'Business model design (one-time)' },
35
+ validationMonth: { fm: 1, label: 'Validation (per founder-month)' },
36
+ applicationScreen: { fm: 1, label: 'Applicant screening (full BMD)' },
37
+ },
38
+ validityMonths: { default: 12, scale: 18 },
39
+ };
40
+
41
+ export type AppliedDiscount = 'none' | 'nfp' | 'ppp';
42
+
43
+ export interface Quote {
44
+ founderMonths: number;
45
+ tier: string;
46
+ rate: number;
47
+ // Pre-discount annual at for-profit rates. The "base" line in the breakdown.
48
+ baseAnnual: number;
49
+ // Final annual after the cheaper of {FP, NFP, PPP-discounted FP} wins,
50
+ // with the margin floor enforced.
51
+ annual: number;
52
+ retailAnnual: number;
53
+ savings: number;
54
+ // Which path produced `annual`. NFP and PPP are mutually exclusive — the
55
+ // calculator picks whichever yields the lower price for the founder, never
56
+ // both stacked, so the breakdown stays one-line and honest.
57
+ appliedDiscount: AppliedDiscount;
58
+ // PPP discount as requested vs. as actually applied. They differ when the
59
+ // margin floor binds (most often at the higher volume tiers). UI surfaces
60
+ // the *effective* number; the nominal stays available for tooltips.
61
+ pppDiscountNominal: number | null;
62
+ pppDiscountEffective: number | null;
63
+ floorActive: boolean;
64
+ perFmRate: number;
65
+ }
66
+
67
+ // True retail anchor for the "below retail" savings line: the individual-founder
68
+ // per-credit rate × credits-per-fm. This is the price a single founder pays
69
+ // for the same fm of usage outside an org contract. Org tiers (including pilot)
70
+ // always sit below this, so savings are non-zero even at the smallest buy.
71
+ export const RETAIL_PER_FM = PRICING.retailPerCredit * PRICING.creditsPerFounderMonth;
72
+
73
+ interface MarginalResult {
74
+ annual: number;
75
+ topTier: PricingTier;
76
+ }
77
+
78
+ function marginalAnnual(fm: number, nfp: boolean): MarginalResult {
79
+ let annual = 0;
80
+ let topTier = PRICING.tiers[0];
81
+ for (const tier of PRICING.tiers) {
82
+ const tierMin = tier.minFm;
83
+ const tierMaxExclusive = tier.maxFm === null ? Infinity : tier.maxFm + 1;
84
+ const fmInTier = Math.max(0, Math.min(fm, tierMaxExclusive) - tierMin);
85
+ if (fmInTier > 0) {
86
+ annual += fmInTier * (nfp ? tier.nfpRate : tier.rate);
87
+ topTier = tier;
88
+ }
89
+ }
90
+ return { annual, topTier };
91
+ }
92
+
93
+ export interface QuoteOptions {
94
+ nfp?: boolean;
95
+ // Nominal PPP discount percentage (0–100) detected for the visitor's country.
96
+ // Pass `null`/`undefined` when PPP shouldn't apply (no country, declined).
97
+ pppDiscount?: number | null;
98
+ // Override the policy floor for testing or future rate moves. Defaults to
99
+ // MARGIN_FLOOR_PER_FM.
100
+ marginFloorPerFm?: number;
101
+ }
102
+
103
+ // Marginal pricing: each tier rate applies only to the founder-months *within*
104
+ // that tier, like income-tax brackets. Eliminates pricing cliffs at tier
105
+ // boundaries where flat-tier pricing could cause "buy more, pay less" inversions.
106
+ //
107
+ // NFP and PPP are evaluated separately and the cheaper of the two wins —
108
+ // they don't stack. The margin floor caps how deep any discount can cut.
109
+ export function quote(fm: number, opts: QuoteOptions = {}): Quote {
110
+ const safeFm = Math.max(0, Math.round(fm));
111
+ const floor = opts.marginFloorPerFm ?? MARGIN_FLOOR_PER_FM;
112
+ const floorAnnual = safeFm * floor;
113
+
114
+ const fp = marginalAnnual(safeFm, false);
115
+ const baseAnnual = fp.annual;
116
+
117
+ const candidates: Array<{ kind: AppliedDiscount; annual: number; topTier: PricingTier }> = [
118
+ { kind: 'none', annual: baseAnnual, topTier: fp.topTier },
119
+ ];
120
+
121
+ if (opts.nfp) {
122
+ const nfp = marginalAnnual(safeFm, true);
123
+ candidates.push({ kind: 'nfp', annual: Math.max(floorAnnual, nfp.annual), topTier: nfp.topTier });
124
+ }
125
+
126
+ let pppDiscountEffective: number | null = null;
127
+ let floorActive = false;
128
+ if (opts.pppDiscount && opts.pppDiscount > 0) {
129
+ const pppRaw = baseAnnual * (1 - opts.pppDiscount / 100);
130
+ const pppCapped = Math.max(floorAnnual, pppRaw);
131
+ floorActive = pppCapped > pppRaw;
132
+ pppDiscountEffective = baseAnnual > 0
133
+ ? Math.round((1 - pppCapped / baseAnnual) * 100)
134
+ : 0;
135
+ candidates.push({ kind: 'ppp', annual: pppCapped, topTier: fp.topTier });
136
+ }
137
+
138
+ // Pick the lowest-priced candidate; ties break toward earlier entries which
139
+ // means "none" beats "nfp" beats "ppp" — preserving the simplest narrative
140
+ // when discounts don't actually save anything.
141
+ const winner = candidates.reduce((best, c) => (c.annual < best.annual ? c : best), candidates[0]);
142
+
143
+ const topRate = winner.kind === 'nfp' ? winner.topTier.nfpRate : winner.topTier.rate;
144
+ const retailAnnual = safeFm * RETAIL_PER_FM;
145
+ const annual = Math.round(winner.annual);
146
+
147
+ return {
148
+ founderMonths: safeFm,
149
+ tier: winner.topTier.name,
150
+ rate: topRate,
151
+ baseAnnual: Math.round(baseAnnual),
152
+ annual,
153
+ retailAnnual: Math.round(retailAnnual),
154
+ savings: Math.max(0, Math.round(retailAnnual - annual)),
155
+ appliedDiscount: winner.kind,
156
+ pppDiscountNominal: opts.pppDiscount && opts.pppDiscount > 0 ? opts.pppDiscount : null,
157
+ pppDiscountEffective: winner.kind === 'ppp' ? pppDiscountEffective : null,
158
+ floorActive: winner.kind === 'ppp' && floorActive,
159
+ perFmRate: safeFm > 0 ? annual / safeFm : 0,
160
+ };
161
+ }
162
+
163
+ // ---------- Academy seat (cert + recurring, with waive modes) ----------
164
+ // Org deals add Certified Coach (Academy) seats on top of founder-months.
165
+ // The seat splits into a one-time certification fee and a recurring monthly
166
+ // fee, both per seat. Waive modes cover grandfathered cohorts (cert waived,
167
+ // recurring still billed) and legacy sponsored cohorts like NEC-X (both
168
+ // waived, $0, but the nominal value is preserved for reporting/quotes).
169
+ export const ACADEMY_CERT_ONETIME = 0; // per seat, one-time — REQUIRED: set to Ash's value
170
+ export const ACADEMY_RECURRING_MONTHLY = 99; // per seat/month — REQUIRED: set to Ash's value
171
+
172
+ export type AcademyWaive = 'none' | 'cert' | 'all';
173
+ export interface AcademyLine {
174
+ seats: number; months: number; nominalCents: number;
175
+ waive: AcademyWaive; amountCents: number; amountLabel: string | null;
176
+ }
177
+
178
+ export function academyLine(
179
+ coachSeats: number, programMonths: number, opts: { waive?: AcademyWaive } = {}
180
+ ): AcademyLine {
181
+ const seats = Math.max(0, Math.round(coachSeats));
182
+ const months = Math.max(0, Math.round(programMonths));
183
+ const waive = opts.waive ?? 'none';
184
+ const certCents = seats * ACADEMY_CERT_ONETIME * 100;
185
+ const recurCents = seats * ACADEMY_RECURRING_MONTHLY * months * 100;
186
+ const nominalCents = certCents + recurCents;
187
+ const amountCents =
188
+ waive === 'all' ? 0 : waive === 'cert' ? recurCents : nominalCents;
189
+ const amountLabel = waive === 'all' ? 'waived' : null;
190
+ return { seats, months, nominalCents, waive, amountCents, amountLabel };
191
+ }
192
+
193
+ // Public calculator composer — dollar-based, unchanged output shape.
194
+ // Retargeted onto academyLine so the $99 Academy line has one code path.
195
+ export interface OrgQuote extends Quote {
196
+ coach: { monthly: number; months: number; cost: number };
197
+ grandTotal: number;
198
+ }
199
+ export function orgQuote(
200
+ fm: number, opts: QuoteOptions & { coachSeats?: number; coachMonths?: number } = {}
201
+ ): OrgQuote {
202
+ const base = quote(fm, opts);
203
+ // Recurring-only: the public calculator's "N × $99/mo × Mmo" line must never
204
+ // absorb the one-time certification fee (ACADEMY_CERT_ONETIME). If the cert
205
+ // fee is later set > 0, deriving coach.cost from academyLine(...,{waive:'none'})
206
+ // would silently inflate this monthly figure while the label still reads
207
+ // "$99/mo" — so compute the recurring portion directly instead.
208
+ const seats = Math.max(0, Math.round(opts.coachSeats ?? 0));
209
+ const months = Math.max(1, Math.round(opts.coachMonths ?? 12));
210
+ const cost = seats * ACADEMY_RECURRING_MONTHLY * months; // recurring only, dollars — cert excluded from the monthly public view
211
+ const coach = { monthly: ACADEMY_RECURRING_MONTHLY, months, cost };
212
+ return { ...base, coach, grandTotal: base.annual + cost };
213
+ }
214
+
215
+ export function formatCurrency(n: number): string {
216
+ return '$' + n.toLocaleString('en-US');
217
+ }
218
+
219
+ export const COACH_CREDIT_MONTHLY = 115;
220
+
221
+ export interface CoachCreditLine {
222
+ coachMonths: number;
223
+ monthly: number;
224
+ amountCents: number;
225
+ credits: number;
226
+ }
227
+
228
+ export function coachCreditLine(coachSeats: number, programMonths: number): CoachCreditLine {
229
+ const seats = Math.max(0, Math.round(coachSeats));
230
+ const months = Math.max(0, Math.round(programMonths));
231
+ const coachMonths = seats * months;
232
+ return {
233
+ coachMonths,
234
+ monthly: COACH_CREDIT_MONTHLY,
235
+ amountCents: coachMonths * COACH_CREDIT_MONTHLY * 100,
236
+ credits: coachMonths * PRICING.creditsPerFounderMonth,
237
+ };
238
+ }
239
+
240
+ // ---------- Unused-balance credit helper (fm-based) ----------
241
+ // Computes the credit due for unused founder-months from a prepaid engagement.
242
+ // Unlike seats-based credits, this calculates on actual founder-month allocation
243
+ // per tier, respecting the fmPerSeat multiplier for each validation/selection tier.
244
+ export interface BalanceTier { seatsPaid: number; fmPerSeat: number; used: number; }
245
+ export interface BalanceCredit {
246
+ unusedSeats: number; unusedFm: number; paidFm: number;
247
+ pct: number; amountCents: number; label: string;
248
+ }
249
+
250
+ export function balanceCredit(input: { tiers: BalanceTier[]; prepaidUsd: number }): BalanceCredit {
251
+ let unusedSeats = 0, unusedFm = 0, paidFm = 0;
252
+ for (const t of input.tiers) {
253
+ const unused = Math.max(0, t.seatsPaid - t.used);
254
+ unusedSeats += unused;
255
+ unusedFm += unused * t.fmPerSeat;
256
+ paidFm += t.seatsPaid * t.fmPerSeat;
257
+ }
258
+ const ratio = paidFm > 0 ? unusedFm / paidFm : 0;
259
+ const pct = ratio * 100;
260
+ // Guard against a signed zero (-0) leaking into the UI as "-$0" when ratio is 0.
261
+ const amountCents = ratio > 0 ? -Math.round(input.prepaidUsd * ratio * 100) : 0;
262
+ const label = `${unusedFm} unused founder-months (${(Math.round(pct * 100) / 100)}%)`;
263
+ return { unusedSeats, unusedFm, paidFm, pct, amountCents, label };
264
+ }
265
+
266
+ // ---------- Accelerator cohort phases (marginal allocation across phases) ----------
267
+ // A cohort program (e.g. NEC-X) runs founders through sequential phases —
268
+ // Selection, then Validation — each with its own seat count and duration.
269
+ // Phases are allocated marginal cost in input order against the same
270
+ // founder-tier ladder `quote()` uses, so a program that crosses a tier
271
+ // boundary mid-phase (e.g. phase 2 starts at fm 90 and ends at fm 110) gets
272
+ // a blended per-fm rate for that phase rather than a single tier's rate.
273
+ export interface CohortPhase { label: string; seats: number; months: number; }
274
+ export interface FounderPhaseLine {
275
+ label: string; fm: number; credits: number;
276
+ amountCents: number; rateLabel: string; spansTier: boolean;
277
+ }
278
+
279
+ // Marginal cost of the fm interval [from, from+len) walked up the for-profit ladder.
280
+ function marginalInterval(from: number, len: number): { cents: number; tiers: Set<string> } {
281
+ let cents = 0; const tiers = new Set<string>();
282
+ for (const t of PRICING.tiers) {
283
+ const lo = Math.max(from, t.minFm);
284
+ const hi = Math.min(from + len, t.maxFm === null ? Infinity : t.maxFm + 1);
285
+ const span = Math.max(0, hi - lo);
286
+ if (span > 0) { cents += span * t.rate * 100; tiers.add(t.name); }
287
+ }
288
+ return { cents, tiers };
289
+ }
290
+
291
+ // For-profit marginal rates only. NFP/PPP are not applied to cohort-phase pricing (deferred follow-up).
292
+ export function allocateFounderPhases(
293
+ phases: CohortPhase[]
294
+ ): FounderPhaseLine[] {
295
+ let cursor = 0;
296
+ return phases.map(p => {
297
+ const fm = Math.max(0, Math.round(p.seats * p.months));
298
+ const { cents, tiers } = marginalInterval(cursor, fm);
299
+ cursor += fm;
300
+ const spansTier = tiers.size > 1;
301
+ const blendedPerMo = fm > 0 ? Math.round(cents / 100 / fm) : 0;
302
+ return {
303
+ label: p.label, fm,
304
+ credits: fm * PRICING.creditsPerFounderMonth,
305
+ amountCents: cents,
306
+ rateLabel: spansTier ? `~$${blendedPerMo}/founder-mo (blended)` : `$${blendedPerMo}/founder-mo`,
307
+ spansTier,
308
+ };
309
+ });
310
+ }
311
+
312
+ // ---------- dealQuote (render-complete composer) ----------
313
+ // Assembles founder phases + coach seats + academy + ad-hoc adjustments
314
+ // (workshops, balance credits, etc.) into an ordered set of integer-cent
315
+ // lines a quote/invoice UI can render directly, plus the true payable total
316
+ // and the credit pool the deal provisions. This is the top-level entry point
317
+ // consumers should call for a full org deal quote.
318
+ export type DealLine = {
319
+ key: string; label: string; qty?: number; qtyLabel?: string;
320
+ rateLabel?: string; credits?: number; amountCents: number; amountLabel?: string | null;
321
+ };
322
+ export interface DealQuote {
323
+ lines: DealLine[];
324
+ subtotalCents: number;
325
+ amountCents: number;
326
+ poolCredits: number;
327
+ }
328
+ export interface DealQuoteInput {
329
+ phases: CohortPhase[]; coachSeats: number; programMonths: number;
330
+ academyWaive?: AcademyWaive;
331
+ adjustments?: { label: string; amountCents: number; credits?: number }[];
332
+ bufferCredits?: number;
333
+ }
334
+
335
+ // For-profit marginal rates only. NFP/PPP are not applied to cohort-phase pricing (deferred follow-up).
336
+ export function dealQuote(input: DealQuoteInput): DealQuote {
337
+ const lines: DealLine[] = [];
338
+
339
+ const founderRows = allocateFounderPhases(input.phases);
340
+ founderRows.forEach((r, i) => {
341
+ lines.push({ key: `founder-${i}`, label: r.label, qty: r.fm, qtyLabel: 'founder-months',
342
+ rateLabel: r.rateLabel, credits: r.credits, amountCents: r.amountCents });
343
+ });
344
+
345
+ const coach = coachCreditLine(input.coachSeats, input.programMonths);
346
+ if (coach.coachMonths > 0) {
347
+ lines.push({ key: 'coach', label: 'Coach seats — Ask + multiplayer',
348
+ qty: coach.coachMonths, qtyLabel: 'coach-months', rateLabel: `$${COACH_CREDIT_MONTHLY}/coach-mo`,
349
+ credits: coach.credits, amountCents: coach.amountCents });
350
+ }
351
+
352
+ const academy = academyLine(input.coachSeats, input.programMonths, { waive: input.academyWaive });
353
+ if (academy.seats > 0) {
354
+ lines.push({ key: 'academy', label: 'Certified Coach — Academy + certification',
355
+ qty: academy.seats, qtyLabel: 'seats', amountCents: academy.amountCents,
356
+ amountLabel: academy.amountLabel });
357
+ }
358
+
359
+ const subtotalCents = lines.reduce((s, l) => s + l.amountCents, 0);
360
+
361
+ let adjCredits = 0;
362
+ (input.adjustments ?? []).forEach((a, i) => {
363
+ lines.push({ key: `adjustment-${i}`, label: a.label, amountCents: a.amountCents, credits: a.credits });
364
+ adjCredits += a.credits ?? 0;
365
+ });
366
+
367
+ const founderCoachCredits = founderRows.reduce((s, r) => s + r.credits, 0) + coach.credits;
368
+ const poolCredits = founderCoachCredits + adjCredits + (input.bufferCredits ?? 0);
369
+ const amountCents = lines.reduce((s, l) => s + l.amountCents, 0);
370
+
371
+ return { lines, subtotalCents, amountCents, poolCredits };
372
+ }