app/utils/offerToStream.ts

183 lines
No EOL
5.2 KiB
TypeScript

import type { Member, Offer } from "~/types/coaching";
interface StreamRow {
id: string;
name: string;
category: string;
subcategory: string;
targetPct: number;
targetMonthlyAmount: number;
certainty: "Committed" | "Probable" | "Aspirational";
payoutDelayDays: number;
terms: string;
revenueSharePct: number;
platformFeePct: number;
restrictions: "Restricted" | "General";
seasonalityWeights: number[];
effortHoursPerMonth: number;
// Extended fields for offer-derived streams
unitPrice?: number;
unitsPerMonth?: number;
feePercent?: number;
notes?: string;
costBreakdown?: {
totalHours: number;
totalCost: number;
memberCosts: Array<{
memberId: string;
memberName: string;
hours: number;
hourlyRate: number;
baseCost: number;
onCostAmount: number;
totalCost: number;
}>;
};
}
interface OfferToStreamOptions {
onCostPercent?: number; // Default 25% on-cost
category?: string; // Default category for offer-derived streams
}
export function offerToStream(
offer: Offer,
members: Member[],
options: OfferToStreamOptions = {}
): StreamRow {
const { onCostPercent = 25, category = "services" } = options;
// Create member lookup map
const memberMap = new Map(members.map(m => [m.id, m]));
// Determine units per month based on offer type
const unitsPerMonth = getUnitsPerMonth(offer);
// Calculate cost breakdown
const costBreakdown = calculateCostBreakdown(offer, memberMap, onCostPercent);
// Generate terms string
const terms = offer.payoutDelayDays <= 15 ? "Net 15" :
offer.payoutDelayDays <= 30 ? "Net 30" :
offer.payoutDelayDays <= 45 ? "Net 45" :
`Net ${offer.payoutDelayDays}`;
// Calculate monthly amount
const targetMonthlyAmount = unitsPerMonth > 0 ? offer.price.baseline * unitsPerMonth : 0;
return {
id: `offer-${offer.id}`,
name: offer.name,
category,
subcategory: inferSubcategory(offer.name),
targetPct: 0, // Will be calculated later when all streams are known
targetMonthlyAmount,
certainty: "Probable" as const, // Offers are more than aspirational but not fully committed
payoutDelayDays: offer.payoutDelayDays,
terms,
revenueSharePct: 0,
platformFeePct: 0,
restrictions: "General" as const,
seasonalityWeights: new Array(12).fill(1),
effortHoursPerMonth: calculateEffortHours(offer),
// Extended fields
unitPrice: offer.price.baseline,
unitsPerMonth,
feePercent: 3, // Default 3% processing/platform fee
notes: offer.whyThis.join(". "),
costBreakdown
};
}
export function offersToStreams(
offers: Offer[],
members: Member[],
options: OfferToStreamOptions = {}
): StreamRow[] {
return offers.map(offer => offerToStream(offer, members, options));
}
// Helper functions
function getUnitsPerMonth(offer: Offer): number {
const offerName = offer.name.toLowerCase();
// Clinics and workshops are typically one-time deliverables per month
if (offerName.includes("clinic") || offerName.includes("workshop")) {
return 1;
}
// Check for specific patterns that indicate recurring work
if (offerName.includes("retainer") || offerName.includes("ongoing")) {
return 1;
}
// Sprints and other project-based work default to 0 (custom/project basis)
return 0;
}
function inferSubcategory(offerName: string): string {
const name = offerName.toLowerCase();
if (name.includes("training") || name.includes("workshop")) {
return "Workshops/teaching";
}
if (name.includes("consulting") || name.includes("review") || name.includes("clinic")) {
return "Consulting";
}
if (name.includes("development") || name.includes("sprint") || name.includes("prototype")) {
return "Contract development";
}
if (name.includes("narrative") || name.includes("writing")) {
return "Technical services";
}
if (name.includes("art") || name.includes("pipeline")) {
return "Technical services";
}
return "Contract development"; // Default fallback
}
function calculateEffortHours(offer: Offer): number {
// Sum up total hours across all members
return offer.hoursByMember.reduce((total, allocation) => total + allocation.hours, 0);
}
function calculateCostBreakdown(
offer: Offer,
memberMap: Map<string, Member>,
onCostPercent: number
): StreamRow["costBreakdown"] {
const memberCosts = offer.hoursByMember.map(allocation => {
const member = memberMap.get(allocation.memberId);
if (!member) {
throw new Error(`Member not found: ${allocation.memberId}`);
}
const baseCost = allocation.hours * member.hourly;
const onCostAmount = baseCost * (onCostPercent / 100);
const totalCost = baseCost + onCostAmount;
return {
memberId: member.id,
memberName: member.name,
hours: allocation.hours,
hourlyRate: member.hourly,
baseCost,
onCostAmount,
totalCost
};
});
const totalHours = memberCosts.reduce((sum, mc) => sum + mc.hours, 0);
const totalCost = memberCosts.reduce((sum, mc) => sum + mc.totalCost, 0);
return {
totalHours,
totalCost,
memberCosts
};
}
// Type export for use in other files
export type { StreamRow };