@cyc-seattle/platform 1.44.0 → 1.45.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,11 @@
1
+ import type { Context } from "./context.ts";
2
+ import type { CostCenterBooker } from "./lines.ts";
3
+ import type { CapitalRank, CostLine, OrganizationTotals } from "./types.ts";
4
+ /** Records each planned purchase as capital on the organization, which pays for all capital. */
5
+ export declare function bookCapitalItems(ctx: Context, booker: CostCenterBooker): void;
6
+ /**
7
+ * The capital purchases in rank order, each with the running total and whether the year's operating
8
+ * and fundraising nets cover it. Capital entered as a budget line comes after the ranked items, with
9
+ * a warning, since it has no rank.
10
+ */
11
+ export declare function rankCapital(ctx: Context, lines: CostLine[], organization: OrganizationTotals): CapitalRank[];
@@ -0,0 +1,60 @@
1
+ const byRank = (a, b) => (a.sort ?? Number.POSITIVE_INFINITY) - (b.sort ?? Number.POSITIVE_INFINITY) || a.name.localeCompare(b.name);
2
+ /** The capital items on an asset account, most needed first. The rest are left out, with a warning. */
3
+ function rankedItems(ctx) {
4
+ return (ctx.data.plan_capital_items ?? [])
5
+ .filter((item) => {
6
+ if (ctx.accountType(item.account_id) === "asset")
7
+ return true;
8
+ ctx.warn("capital_not_asset", `${item.name} isn't on an asset account, so it's left out of capital`, item.key);
9
+ return false;
10
+ })
11
+ .sort(byRank);
12
+ }
13
+ /** Records each planned purchase as capital on the organization, which pays for all capital. */
14
+ export function bookCapitalItems(ctx, booker) {
15
+ for (const item of rankedItems(ctx)) {
16
+ booker.add({
17
+ cost_center_type: "org",
18
+ cost_center_key: null,
19
+ account_id: item.account_id,
20
+ function: ctx.functionFor(item.account_id, null, "org"),
21
+ amount: item.amount_cents,
22
+ line_key: `capital:${item.key}`,
23
+ line_name: item.name,
24
+ explanation: item.name,
25
+ mtdc_share: 0,
26
+ });
27
+ }
28
+ }
29
+ /**
30
+ * The capital purchases in rank order, each with the running total and whether the year's operating
31
+ * and fundraising nets cover it. Capital entered as a budget line comes after the ranked items, with
32
+ * a warning, since it has no rank.
33
+ */
34
+ export function rankCapital(ctx, lines, organization) {
35
+ const budget = lines
36
+ .filter((l) => l.line_key.startsWith("budget:") && ctx.accountType(l.account_id) === "asset")
37
+ .sort((a, b) => a.line_name.localeCompare(b.line_name));
38
+ for (const l of budget) {
39
+ ctx.warn("capital_unranked", `${l.line_name} is capital entered as a budget line, so it comes after the ranked capital list`, l.line_key.slice("budget:".length));
40
+ }
41
+ const available = organization.operating.net_cents + organization.fundraising.net_cents;
42
+ let running = 0;
43
+ return [
44
+ ...rankedItems(ctx).map((i) => ({
45
+ key: i.key,
46
+ name: i.name,
47
+ account_id: i.account_id,
48
+ amount_cents: i.amount_cents,
49
+ })),
50
+ ...budget.map((l) => ({
51
+ key: l.line_key.slice("budget:".length),
52
+ name: l.line_name,
53
+ account_id: l.account_id,
54
+ amount_cents: l.amount_cents,
55
+ })),
56
+ ].map((item, i) => {
57
+ running += item.amount_cents;
58
+ return { rank: i + 1, ...item, running_cents: running, funded: running <= available };
59
+ });
60
+ }
@@ -1,5 +1,6 @@
1
1
  import { scheduleActivities } from "./activities.js";
2
2
  import { bookBonuses } from "./bonus.js";
3
+ import { bookCapitalItems, rankCapital } from "./capital.js";
3
4
  import { Context } from "./context.js";
4
5
  import { bookBudgetLines, bookLaborLines, CostCenterBooker } from "./lines.js";
5
6
  import { mtdc } from "./measures.js";
@@ -22,6 +23,7 @@ export function computePlan(data) {
22
23
  bookRevenue(ctx);
23
24
  const booker = new CostCenterBooker(ctx);
24
25
  bookBudgetLines(ctx, booker);
26
+ bookCapitalItems(ctx, booker);
25
27
  bookLaborLines(ctx, booker);
26
28
  const charged = bookSalaries(ctx, coaching, booker);
27
29
  booker.flush();
@@ -30,12 +32,14 @@ export function computePlan(data) {
30
32
  allocatePools(ctx);
31
33
  const bonuses = bookBonuses(ctx);
32
34
  const lines = sumLines(ctx);
35
+ const organization = organizationTotals(ctx, lines);
33
36
  return {
34
37
  lines,
35
38
  pnl: summarize(ctx, lines, mtdcByOffering),
36
- organization: organizationTotals(ctx, lines),
39
+ organization,
37
40
  activities,
38
41
  positions: summarizePositions(ctx, coaching, charged, bonuses, doubleBooked),
42
+ capital: rankCapital(ctx, lines, organization),
39
43
  warnings: ctx.warnings,
40
44
  };
41
45
  }
@@ -1,7 +1,9 @@
1
1
  import type { Context } from "./context.ts";
2
2
  /**
3
- * Divides everything in each pool among all of the plan year's offerings by MTDC, keeping each
4
- * amount's account and function. Shared costs never count toward MTDC, so one pool's split never shifts
3
+ * Divides everything in each pool among the plan year's programs' offerings by MTDC, keeping each
4
+ * amount's account and function. Non-program's net offsets the pools first (cyc-seattle/platform#242):
5
+ * that part of each pooled amount stays with the organization, so a gain shrinks every program's
6
+ * share and a loss grows it. Shared costs never count toward MTDC, so one pool's split never shifts
5
7
  * another's.
6
8
  */
7
9
  export declare function allocatePools(ctx: Context): void;
@@ -1,11 +1,38 @@
1
1
  import { mtdc } from "./measures.js";
2
+ /** Whether an offering is a program's. Every other offering is non-program, such as a charter. */
3
+ function isProgram(ctx, productId) {
4
+ return ctx.products.get(productId)?.kind === "program";
5
+ }
6
+ /** Non-program's net before the pools: its offerings' revenue less their own costs. */
7
+ function nonProgramNet(ctx) {
8
+ const keys = new Set(ctx
9
+ .fyOfferings()
10
+ .filter((o) => !isProgram(ctx, o.product_id))
11
+ .map((o) => o.key));
12
+ let net = 0;
13
+ for (const e of ctx.entries) {
14
+ if (e.cost_center_type !== "offering" || !keys.has(e.cost_center_key ?? ""))
15
+ continue;
16
+ const type = ctx.accountType(e.account_id);
17
+ if (type === "revenue")
18
+ net += e.amount;
19
+ else if (type === "expense")
20
+ net -= e.amount;
21
+ }
22
+ return net;
23
+ }
2
24
  /**
3
- * Divides everything in each pool among all of the plan year's offerings by MTDC, keeping each
4
- * amount's account and function. Shared costs never count toward MTDC, so one pool's split never shifts
25
+ * Divides everything in each pool among the plan year's programs' offerings by MTDC, keeping each
26
+ * amount's account and function. Non-program's net offsets the pools first (cyc-seattle/platform#242):
27
+ * that part of each pooled amount stays with the organization, so a gain shrinks every program's
28
+ * share and a loss grows it. Shared costs never count toward MTDC, so one pool's split never shifts
5
29
  * another's.
6
30
  */
7
31
  export function allocatePools(ctx) {
8
- const weights = new Map(ctx.fyOfferings().map((o) => [o.key, Math.max(0, mtdc(ctx, o.key))]));
32
+ const weights = new Map(ctx
33
+ .fyOfferings()
34
+ .filter((o) => isProgram(ctx, o.product_id))
35
+ .map((o) => [o.key, Math.max(0, mtdc(ctx, o.key))]));
9
36
  const total = [...weights.values()].reduce((t, w) => t + w, 0);
10
37
  const pools = new Map(ctx.data.plan_pools.map((p) => [p.key, p]));
11
38
  const pooled = [];
@@ -14,27 +41,52 @@ export function allocatePools(ctx) {
14
41
  (e.cost_center_type === "pool" ? pooled : kept).push(e);
15
42
  if (pooled.length === 0)
16
43
  return;
44
+ const net = nonProgramNet(ctx);
17
45
  ctx.entries.splice(0, ctx.entries.length, ...kept);
46
+ const overhead = pooled.reduce((t, e) => t + e.amount, 0);
47
+ // Each pooled amount gives up the same fraction, so the offset falls on each pool in proportion to its size.
48
+ let offset = overhead > 0 ? net / overhead : 0;
49
+ if (offset > 1) {
50
+ ctx.warn("non_program_exceeds_pools", `Non-program makes more than all the shared costs, so ${ctx.data.organization.name} keeps all of them`);
51
+ offset = 1;
52
+ }
18
53
  if (total <= 0) {
19
- ctx.warn("no_mtdc", `No offering this year has costs that count toward MTDC, so ${ctx.data.organization.name} keeps all the shared costs`);
54
+ ctx.warn("no_mtdc", `No program's offering this year has costs that count toward MTDC, so ${ctx.data.organization.name} keeps all the shared costs`);
20
55
  }
21
56
  for (const e of pooled) {
22
- const pool = pools.get(e.cost_center_key ?? "");
57
+ const name = pools.get(e.cost_center_key ?? "")?.name ?? "Pool";
23
58
  const base = {
24
59
  account_id: e.account_id,
25
60
  function: e.function,
26
- line_key: `pool:${e.cost_center_key}:${e.line_key}`,
27
- line_name: `${pool?.name ?? "Pool"} share: ${e.line_name}`,
28
- explanation: `${pool?.name ?? "Pool"}: ${e.explanation}`,
29
61
  stage: "pool",
30
62
  mtdc_share: 0,
31
63
  };
64
+ const share = {
65
+ ...base,
66
+ line_key: `pool:${e.cost_center_key}:${e.line_key}`,
67
+ line_name: `${name} share: ${e.line_name}`,
68
+ explanation: `${name}: ${e.explanation}`,
69
+ };
32
70
  if (total <= 0) {
33
- ctx.book({ ...base, cost_center_type: "org", cost_center_key: null, amount: e.amount });
71
+ ctx.book({ ...share, cost_center_type: "org", cost_center_key: null, amount: e.amount });
34
72
  continue;
35
73
  }
74
+ ctx.book({
75
+ ...base,
76
+ cost_center_type: "org",
77
+ cost_center_key: null,
78
+ amount: e.amount * offset,
79
+ line_key: `offset:${e.cost_center_key}:${e.line_key}`,
80
+ line_name: `${name} covered by non-program: ${e.line_name}`,
81
+ explanation: `${name}: ${e.explanation}, the part non-program's net covers`,
82
+ });
36
83
  for (const [key, w] of weights) {
37
- ctx.book({ ...base, cost_center_type: "offering", cost_center_key: key, amount: (e.amount * w) / total });
84
+ ctx.book({
85
+ ...share,
86
+ cost_center_type: "offering",
87
+ cost_center_key: key,
88
+ amount: (e.amount * (1 - offset) * w) / total,
89
+ });
38
90
  }
39
91
  }
40
92
  }
@@ -188,6 +188,18 @@ export interface PlanBudgetLine extends Versioned {
188
188
  excluded_from_mtdc: boolean;
189
189
  note: string | null;
190
190
  }
191
+ /**
192
+ * Something lasting the Operations Plan means to buy, ranked by `sort` with the most needed first.
193
+ * Its account is an asset account.
194
+ */
195
+ export interface PlanCapitalItem extends Versioned {
196
+ name: string;
197
+ amount_cents: number;
198
+ account_id: Id;
199
+ sort: number | null;
200
+ note?: string | null;
201
+ link?: string | null;
202
+ }
191
203
  /** The year's rates. An Operations plan version has at most one row. */
192
204
  export interface PlanRates extends Versioned {
193
205
  /** Employer payroll costs, as a share of wages. */
@@ -308,6 +320,7 @@ export interface PlanData {
308
320
  plan_bonus_owners: PlanBonusOwner[];
309
321
  plan_bonus_owner_products: PlanBonusOwnerProduct[];
310
322
  plan_closed_dates?: PlanClosedDate[];
323
+ plan_capital_items?: PlanCapitalItem[];
311
324
  }
312
325
  /** One line of cost or revenue for the plan year, on one offering, product, pool or the organization. */
313
326
  export interface CostLine {
@@ -388,6 +401,22 @@ export interface OrganizationTotals {
388
401
  /** Operating net, plus fundraising net, less capital. */
389
402
  total_cents: number;
390
403
  }
404
+ /**
405
+ * One planned purchase in the ranked capital list: what's left of the year's money after operations
406
+ * and fundraising pays for each in rank order, as far as it goes.
407
+ */
408
+ export interface CapitalRank {
409
+ rank: number;
410
+ /** The `plan_capital_items` key, or for capital entered as a budget line, that line's key. */
411
+ key: Id;
412
+ name: string;
413
+ account_id: Id;
414
+ amount_cents: number;
415
+ /** This item's amount plus every item ranked above it. */
416
+ running_cents: number;
417
+ /** Whether the operating net plus the fundraising net covers `running_cents`. */
418
+ funded: boolean;
419
+ }
391
420
  /** One salaried position's year: what it's paid, and where its hours went. */
392
421
  export interface PositionSummary {
393
422
  position_key: Id;
@@ -421,5 +450,7 @@ export interface EngineOutput {
421
450
  activities: ComputedActivity[];
422
451
  /** Each salaried position's pay for the year. */
423
452
  positions: PositionSummary[];
453
+ /** The planned capital purchases, most needed first. */
454
+ capital: CapitalRank[];
424
455
  warnings: EngineWarning[];
425
456
  }
@@ -39,6 +39,7 @@ export interface Schema {
39
39
  plan_bonus_owner_products: PlanBonusOwnerProducts[];
40
40
  plan_bonus_owners: PlanBonusOwners[];
41
41
  plan_budget_lines: PlanBudgetLines[];
42
+ plan_capital_items: PlanCapitalItems[];
42
43
  plan_closed_dates: PlanClosedDates[];
43
44
  plan_labor_lines: PlanLaborLines[];
44
45
  plan_offerings: PlanOfferings[];
@@ -46,6 +47,7 @@ export interface Schema {
46
47
  plan_programs: PlanPrograms[];
47
48
  plan_rates: PlanRates[];
48
49
  plan_resources: PlanResources[];
50
+ plan_result_capital: PlanResultCapital[];
49
51
  plan_result_lines: PlanResultLines[];
50
52
  plan_result_pnl: PlanResultPnl[];
51
53
  plan_result_positions: PlanResultPositions[];
@@ -646,6 +648,26 @@ export interface PlanBudgetLines {
646
648
  excluded_from_mtdc: boolean;
647
649
  note: string | null;
648
650
  }
651
+ /** Something lasting CSC means to buy, ranked with the most needed first. */
652
+ export interface PlanCapitalItems {
653
+ id: string;
654
+ /** The version this row belongs to. Capital goes in the Operations Plan's. */
655
+ version_id: string | PlanVersions;
656
+ /** Stays the same across versions. Copy it where another row asks for this one's Key. */
657
+ key: string;
658
+ /** What to buy, such as a coach boat. */
659
+ name: string;
660
+ /** In cents: 12500 is $125.00. */
661
+ amount_cents: number;
662
+ /** The asset account it goes on, such as Equipment. */
663
+ account_id: string | Accounts;
664
+ /** 1 is the most needed. Drag rows in the table to reorder them. */
665
+ sort: number | null;
666
+ /** Why CSC needs it. */
667
+ note: string | null;
668
+ /** A quote or listing for it, as a web address. */
669
+ link: string | null;
670
+ }
649
671
  /** A day the organization is closed, such as Labor Day. The P&L warns about an activity on one. */
650
672
  export interface PlanClosedDates {
651
673
  id: string;
@@ -760,6 +782,22 @@ export interface PlanResources {
760
782
  /** Set for a rental, whose cost is left out of modified total direct costs (MTDC). */
761
783
  excluded_from_mtdc: boolean;
762
784
  }
785
+ /** The capital list in rank order, in a Financial Plan, with what the year's money covers. A draft's are worked out again a moment after each change to it, and a saved one's when it's saved, and then never change. */
786
+ export interface PlanResultCapital {
787
+ id: string;
788
+ /** The Financial Plan these results are for. */
789
+ version_set_id: string | VersionSets;
790
+ /** 1 is the most needed. */
791
+ rank: number | null;
792
+ item_key: string;
793
+ name: string | null;
794
+ /** In cents: 12500 is $125.00. */
795
+ amount_cents: number | null;
796
+ /** This and everything ranked above it. In cents: 12500 is $125.00. */
797
+ running_cents: number | null;
798
+ /** Whether what's left after operations and fundraising covers it, with everything ranked above it. */
799
+ funded: boolean | null;
800
+ }
763
801
  /** Each cost and revenue line in a Financial Plan's P&L. A draft's are worked out again a moment after each change to it, and a saved one's when it's saved, and then never change. */
764
802
  export interface PlanResultLines {
765
803
  id: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cyc-seattle/platform",
3
- "version": "1.44.0",
3
+ "version": "1.45.0",
4
4
  "description": "Pulumi components for CYC's shared Google Cloud platform: WebService, the platform outputs reader, and the stacks that build the platform",
5
5
  "author": "CYC Community Sailing Center",
6
6
  "license": "Apache-2.0",