@cyc-seattle/platform 1.15.0 → 1.16.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.
@@ -1,4 +1,4 @@
1
- import type { Account, AccountFunction, CostCenterType, CostType, DateString, EngineWarning, Id, PlanData, PlanOffering, PlanRates, Product } from "./types.ts";
1
+ import type { Account, AccountFunction, CostCenterType, CostType, DateString, EngineWarning, Id, PlanData, PlanOffering, Plan, PlanRates, Product, ProgramArea } from "./types.ts";
2
2
  /** The step that recorded an entry. Only `direct` entries count toward MTDC. */
3
3
  export type Stage = "direct" | "pool" | "bonus";
4
4
  /** One amount, in fractional cents, before lines are rounded and summed. */
@@ -28,6 +28,9 @@ export declare class Context {
28
28
  readonly warnings: EngineWarning[];
29
29
  readonly accounts: Map<Id, Account>;
30
30
  readonly products: Map<Id, Product>;
31
+ readonly areas: Map<Id, ProgramArea>;
32
+ /** Each version's plan. */
33
+ readonly planOfVersion: Map<Id, Plan>;
31
34
  readonly offeringsById: Map<Id, PlanOffering>;
32
35
  readonly offeringsByKey: Map<Id, PlanOffering>;
33
36
  /** Enrollment per offering id. */
@@ -47,6 +50,8 @@ export declare class Context {
47
50
  accountType(accountId: Id): Account["type"] | undefined;
48
51
  /** Whether `accountId` is `ancestorId` or under it. */
49
52
  accountUnder(accountId: Id, ancestorId: Id): boolean;
53
+ /** The account a product's sales are booked to: its program area's, or its own outside an area. */
54
+ revenueAccount(product: Product): Id | null;
50
55
  /** Whether the offering counts in the plan year: it does, whole, in the year it starts. */
51
56
  inYear(offering: PlanOffering): boolean;
52
57
  /** The plan year's offerings. */
@@ -7,6 +7,9 @@ export class Context {
7
7
  warnings = [];
8
8
  accounts;
9
9
  products;
10
+ areas;
11
+ /** Each version's plan. */
12
+ planOfVersion;
10
13
  offeringsById;
11
14
  offeringsByKey;
12
15
  /** Enrollment per offering id. */
@@ -21,6 +24,12 @@ export class Context {
21
24
  this.fy = fiscalYearDates(data.fiscal_year, data.organization.fiscal_year_start_month);
22
25
  this.accounts = new Map(data.accounts.map((a) => [a.id, a]));
23
26
  this.products = new Map(data.products.map((p) => [p.id, p]));
27
+ this.areas = new Map(data.program_areas.map((a) => [a.id, a]));
28
+ const plans = new Map(data.plans.map((p) => [p.id, p]));
29
+ this.planOfVersion = new Map(data.plan_versions.flatMap((v) => {
30
+ const plan = plans.get(v.plan_id);
31
+ return plan === undefined ? [] : [[v.id, plan]];
32
+ }));
24
33
  this.offeringsById = new Map(data.plan_offerings.map((o) => [o.id, o]));
25
34
  this.offeringsByKey = new Map(data.plan_offerings.map((o) => [o.key, o]));
26
35
  this.rates = data.plan_rates[0];
@@ -57,7 +66,9 @@ export class Context {
57
66
  return account.default_function;
58
67
  if (account?.type === "revenue")
59
68
  return null;
60
- return costCenter === "offering" || costCenter === "product" ? "program" : "management";
69
+ return costCenter === "offering" || costCenter === "product" || costCenter === "program_area"
70
+ ? "program"
71
+ : "management";
61
72
  }
62
73
  accountType(accountId) {
63
74
  return this.accounts.get(accountId)?.type;
@@ -73,6 +84,16 @@ export class Context {
73
84
  }
74
85
  return false;
75
86
  }
87
+ /** The account a product's sales are booked to: its program area's, or its own outside an area. */
88
+ revenueAccount(product) {
89
+ const area = product.program_area_id == null ? undefined : this.areas.get(product.program_area_id);
90
+ if (area === undefined)
91
+ return product.revenue_account_id;
92
+ if (product.revenue_account_id != null && product.revenue_account_id !== area.revenue_account_id) {
93
+ this.warn("revenue_account_differs", `Product ${product.name} has its own revenue account, but its program area ${area.name}'s is used`, product.id);
94
+ }
95
+ return area.revenue_account_id;
96
+ }
76
97
  /** Whether the offering counts in the plan year: it does, whole, in the year it starts. */
77
98
  inYear(offering) {
78
99
  return within(offering.starts_on, this.fy.from, this.fy.to);
@@ -16,6 +16,7 @@ export function computePlan(data) {
16
16
  const ctx = new Context(data);
17
17
  for (const o of data.plan_offerings)
18
18
  ctx.enrollment.set(o.id, enrollmentOf(o));
19
+ checkAreas(ctx);
19
20
  const { activities, coaching } = new Staffing(ctx).run(scheduleActivities(ctx));
20
21
  countDays(ctx, activities);
21
22
  bookRevenue(ctx);
@@ -29,7 +30,70 @@ export function computePlan(data) {
29
30
  allocatePools(ctx);
30
31
  bookBonuses(ctx);
31
32
  const lines = sumLines(ctx);
32
- return { lines, pnl: summarize(ctx, lines, mtdcByOffering), activities, warnings: ctx.warnings };
33
+ return {
34
+ lines,
35
+ pnl: summarize(ctx, lines, mtdcByOffering),
36
+ organization: organizationTotals(ctx, lines),
37
+ activities,
38
+ warnings: ctx.warnings,
39
+ };
40
+ }
41
+ /**
42
+ * Warns about a program with no program area, an area on another kind of product, and an offering
43
+ * in the wrong plan: a program area's plan holds only its own programs, and the Operations Plan only
44
+ * products in no area. The catalog stays live, so the database can't check these.
45
+ */
46
+ function checkAreas(ctx) {
47
+ for (const offering of ctx.fyOfferings()) {
48
+ const product = ctx.products.get(offering.product_id);
49
+ if (product === undefined)
50
+ continue;
51
+ if (product.kind === "program" && product.program_area_id == null) {
52
+ ctx.warn("program_without_area", `Program ${product.name} has no program area`, product.id);
53
+ }
54
+ else if (product.kind !== "program" && product.program_area_id != null) {
55
+ ctx.warn("area_on_non_program", `Product ${product.name} isn't a program, but has a program area`, product.id);
56
+ }
57
+ const plan = ctx.planOfVersion.get(offering.version_id);
58
+ if (plan === undefined)
59
+ continue;
60
+ const wrong = plan.kind === "operations"
61
+ ? product.program_area_id != null
62
+ : product.program_area_id != null && product.program_area_id !== plan.program_area_id;
63
+ if (wrong) {
64
+ const where = plan.kind === "operations" ? "the Operations Plan" : "another program area's plan";
65
+ ctx.warn("offering_in_wrong_plan", `Offering ${offering.name} of ${product.name} is in ${where}`, offering.key);
66
+ }
67
+ }
68
+ }
69
+ /**
70
+ * The organization's year from every line. Capital is any line on an asset account; fundraising is
71
+ * revenue and expense whose function is `fundraising`; everything else is operating.
72
+ */
73
+ function organizationTotals(ctx, lines) {
74
+ const operating = { revenue_cents: 0, expense_cents: 0, net_cents: 0 };
75
+ const fundraising = { revenue_cents: 0, expense_cents: 0, net_cents: 0 };
76
+ let capital = 0;
77
+ for (const l of lines) {
78
+ const type = ctx.accountType(l.account_id);
79
+ if (type === "asset") {
80
+ capital += l.amount_cents;
81
+ continue;
82
+ }
83
+ const totals = l.function === "fundraising" ? fundraising : operating;
84
+ if (type === "revenue")
85
+ totals.revenue_cents += l.amount_cents;
86
+ else if (type === "expense")
87
+ totals.expense_cents += l.amount_cents;
88
+ }
89
+ for (const t of [operating, fundraising])
90
+ t.net_cents = t.revenue_cents - t.expense_cents;
91
+ return {
92
+ operating,
93
+ fundraising,
94
+ capital_cents: capital,
95
+ total_cents: operating.net_cents + fundraising.net_cents - capital,
96
+ };
33
97
  }
34
98
  /** Sums entries into lines by cost center, account, function and line key, in whole cents. */
35
99
  function sumLines(ctx) {
@@ -64,7 +128,7 @@ function sumLines(ctx) {
64
128
  })
65
129
  .filter((l) => l.amount_cents !== 0);
66
130
  }
67
- /** Each plan-year offering's P&L, then each product's. */
131
+ /** Each plan-year offering's P&L, then each product's, then each program area's. */
68
132
  function summarize(ctx, lines, mtdcByOffering) {
69
133
  const pnl = (level, key, own, counts) => {
70
134
  const byAccount = new Map();
@@ -113,7 +177,33 @@ function summarize(ctx, lines, mtdcByOffering) {
113
177
  sailor_days: add("sailor_days"),
114
178
  });
115
179
  });
116
- return [...offeringPnl, ...productPnl];
180
+ const areaIds = new Set();
181
+ for (const o of offerings) {
182
+ const area = ctx.products.get(o.product_id)?.program_area_id;
183
+ if (area != null)
184
+ areaIds.add(area);
185
+ }
186
+ for (const l of lines)
187
+ if (l.cost_center_type === "program_area" && l.cost_center_key !== null)
188
+ areaIds.add(l.cost_center_key);
189
+ const areaPnl = [...areaIds].sort().map((id) => {
190
+ const own = offeringPnl.filter((s) => {
191
+ const product = ctx.offeringsByKey.get(s.key)?.product_id;
192
+ return product !== undefined && ctx.products.get(product)?.program_area_id === id;
193
+ });
194
+ const keys = new Set(own.map((s) => s.key));
195
+ const products = new Set(own.map((s) => ctx.offeringsByKey.get(s.key)?.product_id));
196
+ const add = (field) => own.reduce((t, s) => t + s[field], 0);
197
+ return pnl("program_area", id, lines.filter((l) => (l.cost_center_type === "offering" && l.cost_center_key !== null && keys.has(l.cost_center_key)) ||
198
+ (l.cost_center_type === "product" && products.has(l.cost_center_key ?? undefined)) ||
199
+ (l.cost_center_type === "program_area" && l.cost_center_key === id)), {
200
+ mtdc_cents: add("mtdc_cents"),
201
+ enrollment: add("enrollment"),
202
+ days: add("days"),
203
+ sailor_days: add("sailor_days"),
204
+ });
205
+ });
206
+ return [...offeringPnl, ...productPnl, ...areaPnl];
117
207
  }
118
208
  function divide(totals, by) {
119
209
  if (by <= 0)
@@ -1,8 +1,9 @@
1
1
  import type { Context, EntryInput } from "./context.ts";
2
2
  /**
3
3
  * Records amounts on their cost centers. Amounts on an offering, a pool or the organization are
4
- * recorded right away. Amounts on a product wait until `flush`, then are divided among its offerings
5
- * by MTDC, so every offering's own costs are in first, whatever order the lines come in.
4
+ * recorded right away. Amounts on a product or program area wait until `flush`, then are divided
5
+ * among its offerings by MTDC, so every offering's own costs are in first, whatever order the lines
6
+ * come in.
6
7
  */
7
8
  export declare class CostCenterBooker {
8
9
  private readonly ctx;
@@ -13,7 +14,10 @@ export declare class CostCenterBooker {
13
14
  add(entry: EntryInput): void;
14
15
  flush(): void;
15
16
  }
16
- /** Records the plan year's `plan_budget_lines`. */
17
+ /**
18
+ * Records the plan year's `plan_budget_lines`. Capital, a line on an asset account, is the
19
+ * organization's: fundraising pays for it, so it never reaches a program area or an offering.
20
+ */
17
21
  export declare function bookBudgetLines(ctx: Context, booker: CostCenterBooker): void;
18
22
  /** Records `plan_labor_lines`: hours × the staff level's rate, as wages. */
19
23
  export declare function bookLaborLines(ctx: Context, booker: CostCenterBooker): void;
@@ -1,8 +1,9 @@
1
1
  import { spreadWeights } from "./measures.js";
2
2
  /**
3
3
  * Records amounts on their cost centers. Amounts on an offering, a pool or the organization are
4
- * recorded right away. Amounts on a product wait until `flush`, then are divided among its offerings
5
- * by MTDC, so every offering's own costs are in first, whatever order the lines come in.
4
+ * recorded right away. Amounts on a product or program area wait until `flush`, then are divided
5
+ * among its offerings by MTDC, so every offering's own costs are in first, whatever order the lines
6
+ * come in.
6
7
  */
7
8
  export class CostCenterBooker {
8
9
  ctx;
@@ -11,24 +12,27 @@ export class CostCenterBooker {
11
12
  constructor(ctx) {
12
13
  this.ctx = ctx;
13
14
  }
14
- weightsFor(productId) {
15
- if (!this.weights.has(productId))
16
- this.weights.set(productId, spreadWeights(this.ctx, productId));
17
- return this.weights.get(productId) ?? null;
15
+ weightsFor(type, key) {
16
+ const id = `${type}:${key}`;
17
+ if (!this.weights.has(id))
18
+ this.weights.set(id, spreadWeights(this.ctx, type, key));
19
+ return this.weights.get(id) ?? null;
18
20
  }
19
21
  add(entry) {
20
- if (entry.cost_center_type === "product" && entry.cost_center_key != null)
22
+ const spread = entry.cost_center_type === "product" || entry.cost_center_type === "program_area";
23
+ if (spread && entry.cost_center_key != null)
21
24
  this.pending.push(entry);
22
25
  else
23
26
  this.ctx.book(entry);
24
27
  }
25
28
  flush() {
26
29
  const pending = this.pending.splice(0);
27
- const weights = pending.map((entry) => this.weightsFor(entry.cost_center_key));
30
+ const weights = pending.map((entry) => this.weightsFor(entry.cost_center_type, entry.cost_center_key));
28
31
  pending.forEach((entry, i) => {
29
32
  const w = weights[i] ?? null;
30
33
  if (w === null) {
31
- this.ctx.warn("product_without_offerings", `Product ${entry.cost_center_key} has no offering in the plan year, so ${entry.line_key} stays at the product`, entry.cost_center_key ?? undefined);
34
+ const what = entry.cost_center_type === "product" ? "Product" : "Program area";
35
+ this.ctx.warn(`${entry.cost_center_type}_without_offerings`, `${what} ${entry.cost_center_key} has no offering in the plan year, so ${entry.line_key} stays there`, entry.cost_center_key ?? undefined);
32
36
  this.ctx.book(entry);
33
37
  return;
34
38
  }
@@ -42,23 +46,32 @@ function checkCostCenter(ctx, type, key, line) {
42
46
  const known = type === "org" ||
43
47
  (type === "offering" && key != null && ctx.offeringsByKey.has(key)) ||
44
48
  (type === "product" && key != null && ctx.products.has(key)) ||
49
+ (type === "program_area" && key != null && ctx.areas.has(key)) ||
45
50
  (type === "pool" && key != null && ctx.data.plan_pools.some((p) => p.key === key));
46
51
  if (!known)
47
52
  ctx.warn("unknown_cost_center", `Line ${line} is on a missing ${type} ${key ?? ""}`, line);
48
53
  return known;
49
54
  }
50
- /** Records the plan year's `plan_budget_lines`. */
55
+ /**
56
+ * Records the plan year's `plan_budget_lines`. Capital, a line on an asset account, is the
57
+ * organization's: fundraising pays for it, so it never reaches a program area or an offering.
58
+ */
51
59
  export function bookBudgetLines(ctx, booker) {
52
60
  for (const line of ctx.data.plan_budget_lines) {
53
61
  if (line.fiscal_year !== ctx.data.fiscal_year)
54
62
  continue;
55
63
  if (!checkCostCenter(ctx, line.cost_center_type, line.cost_center_key, line.key))
56
64
  continue;
65
+ const capital = ctx.accountType(line.account_id) === "asset";
66
+ const onOrg = line.cost_center_type === "org" || capital;
67
+ if (capital && line.cost_center_type !== "org") {
68
+ ctx.warn("capital_off_org", `Line ${line.name} is capital, which only the organization pays for, so it's moved there`, line.key);
69
+ }
57
70
  booker.add({
58
- cost_center_type: line.cost_center_type,
59
- cost_center_key: line.cost_center_type === "org" ? null : line.cost_center_key,
71
+ cost_center_type: onOrg ? "org" : line.cost_center_type,
72
+ cost_center_key: onOrg ? null : line.cost_center_key,
60
73
  account_id: line.account_id,
61
- function: ctx.functionFor(line.account_id, line.function, line.cost_center_type),
74
+ function: ctx.functionFor(line.account_id, line.function, onOrg ? "org" : line.cost_center_type),
62
75
  amount: line.amount_cents,
63
76
  line_key: `budget:${line.key}`,
64
77
  explanation: line.name,
@@ -3,5 +3,8 @@ import type { Id } from "./types.ts";
3
3
  /** Modified total direct costs: an offering's direct expenses, less those excluded from MTDC. */
4
4
  export declare function mtdc(ctx: Context, offeringKey: Id): number;
5
5
  export declare function revenue(ctx: Context, offeringKey: Id): number;
6
- /** How a product-level amount divides among the product's plan-year offerings, by MTDC. Null when it has none. */
7
- export declare function spreadWeights(ctx: Context, productId: Id): Map<Id, number> | null;
6
+ /**
7
+ * How an amount on a product or program area divides among its plan-year offerings, by MTDC. Null
8
+ * when it has none.
9
+ */
10
+ export declare function spreadWeights(ctx: Context, type: "product" | "program_area", key: Id): Map<Id, number> | null;
@@ -18,9 +18,14 @@ export function mtdc(ctx, offeringKey) {
18
18
  export function revenue(ctx, offeringKey) {
19
19
  return sumEntries(ctx, offeringKey, (e) => (ctx.accountType(e.account_id) === "revenue" ? 1 : 0));
20
20
  }
21
- /** How a product-level amount divides among the product's plan-year offerings, by MTDC. Null when it has none. */
22
- export function spreadWeights(ctx, productId) {
23
- const offerings = ctx.fyOfferings().filter((o) => o.product_id === productId);
21
+ /**
22
+ * How an amount on a product or program area divides among its plan-year offerings, by MTDC. Null
23
+ * when it has none.
24
+ */
25
+ export function spreadWeights(ctx, type, key) {
26
+ const offerings = ctx
27
+ .fyOfferings()
28
+ .filter((o) => type === "product" ? o.product_id === key : ctx.products.get(o.product_id)?.program_area_id === key);
24
29
  if (offerings.length === 0)
25
30
  return null;
26
31
  const weights = new Map(offerings.map((o) => [o.key, Math.max(0, mtdc(ctx, o.key))]));
@@ -2,5 +2,9 @@ import type { Context } from "./context.ts";
2
2
  import type { ComputedActivity } from "./types.ts";
3
3
  /** Counts each offering's days: the dates it has an activity other than travel. */
4
4
  export declare function countDays(ctx: Context, activities: ComputedActivity[]): void;
5
- /** Records each offering's revenue, price × enrollment, with its COGS and card fees. */
5
+ /**
6
+ * Records each offering's revenue, price × enrollment, with its COGS and the Operations Plan's
7
+ * charges set as a share of revenue. A charge on a revenue account adds revenue; on an expense
8
+ * account, a cost.
9
+ */
6
10
  export declare function bookRevenue(ctx: Context): void;
@@ -9,7 +9,11 @@ export function countDays(ctx, activities) {
9
9
  for (const [key, set] of dates)
10
10
  ctx.days.set(key, set.size);
11
11
  }
12
- /** Records each offering's revenue, price × enrollment, with its COGS and card fees. */
12
+ /**
13
+ * Records each offering's revenue, price × enrollment, with its COGS and the Operations Plan's
14
+ * charges set as a share of revenue. A charge on a revenue account adds revenue; on an expense
15
+ * account, a cost.
16
+ */
13
17
  export function bookRevenue(ctx) {
14
18
  for (const offering of ctx.fyOfferings()) {
15
19
  const product = ctx.products.get(offering.product_id);
@@ -22,11 +26,12 @@ export function bookRevenue(ctx) {
22
26
  const total = price * enrollment;
23
27
  if (total === 0)
24
28
  continue;
25
- if (product.revenue_account_id == null) {
29
+ const revenueAccount = ctx.revenueAccount(product);
30
+ if (revenueAccount == null) {
26
31
  ctx.warn("no_revenue_account", `Product ${product.name} has no revenue account`, product.id);
27
32
  continue;
28
33
  }
29
- const book = (account, amount, line, explanation) => ctx.book({
34
+ const book = (account, amount, line, explanation, mtdc_share = 1) => ctx.book({
30
35
  cost_center_type: "offering",
31
36
  cost_center_key: offering.key,
32
37
  account_id: account,
@@ -34,18 +39,18 @@ export function bookRevenue(ctx) {
34
39
  amount,
35
40
  line_key: `${line}:${offering.key}`,
36
41
  explanation,
42
+ mtdc_share,
37
43
  });
38
- book(product.revenue_account_id, total, "revenue", `${price}¢ × ${enrollment} enrolled`);
44
+ book(revenueAccount, total, "revenue", `${price}¢ × ${enrollment} enrolled`);
39
45
  if (offering.cogs_rate != null && offering.cogs_rate > 0) {
40
46
  const cogs = ctx.costAccount("cogs");
41
47
  if (cogs !== undefined)
42
48
  book(cogs, total * offering.cogs_rate, "cogs", `${offering.cogs_rate} × revenue`);
43
49
  }
44
- const cardFeeRate = ctx.rates?.card_fee_rate ?? 0;
45
- if (cardFeeRate > 0) {
46
- const fees = ctx.costAccount("card_fees");
47
- if (fees !== undefined)
48
- book(fees, total * cardFeeRate, "card_fees", `${cardFeeRate} × revenue`);
50
+ for (const charge of ctx.data.plan_revenue_charges) {
51
+ if (!(charge.product_kinds ?? []).includes(product.kind))
52
+ continue;
53
+ book(charge.account_id, total * charge.rate, `charge:${charge.key}`, `${charge.name}: ${charge.rate} × revenue`, charge.excluded_from_mtdc ? 0 : 1);
49
54
  }
50
55
  }
51
56
  }
@@ -32,12 +32,22 @@ export function bookSalaries(ctx, coaching, booker) {
32
32
  })),
33
33
  ...ctx.data.plan_salaried_bookings
34
34
  .filter((b) => b.position_id === position.id)
35
- .map((b) => ({
36
- cost_center_type: "product",
37
- cost_center_key: b.product_id,
38
- hours: b.hours,
39
- line: b.kind,
40
- })),
35
+ .flatMap((b) => {
36
+ if ((b.product_id == null) === (b.program_area_id == null)) {
37
+ ctx.warn("booking_cost_center", `Booking ${b.key} must name a product or a program area, not both or neither, so it's left out`, b.key);
38
+ return [];
39
+ }
40
+ return [
41
+ b.product_id != null
42
+ ? { cost_center_type: "product", cost_center_key: b.product_id, hours: b.hours, line: b.kind }
43
+ : {
44
+ cost_center_type: "program_area",
45
+ cost_center_key: b.program_area_id,
46
+ hours: b.hours,
47
+ line: b.kind,
48
+ },
49
+ ];
50
+ }),
41
51
  ];
42
52
  if (rate === null && charges.length > 0) {
43
53
  ctx.warn("no_paid_hours", `${position.name} has hours charged to programs but no paid_hours_per_year, so they cost nothing`, position.key);
@@ -11,13 +11,23 @@ export interface Versioned {
11
11
  /** Stable across versions; references from other plans use it. */
12
12
  key: Id;
13
13
  }
14
- export type CostCenterType = "offering" | "product" | "pool" | "org";
14
+ export type CostCenterType = "offering" | "product" | "program_area" | "pool" | "org";
15
15
  export type AccountFunction = "program" | "management" | "fundraising";
16
+ export type ProductKind = "program" | "charter" | "storage_lease" | "merchandise" | "event_hosting" | "other";
16
17
  export interface Product {
17
18
  id: Id;
18
19
  name: string;
19
- kind: "program" | "charter" | "storage_lease" | "merchandise" | "event_hosting" | "other";
20
+ kind: ProductKind;
21
+ /** For a product in no program area. A product in one uses the area's. */
20
22
  revenue_account_id: Id | null;
23
+ /** Every program has one; merchandise, storage and charters have none. */
24
+ program_area_id: Id | null;
25
+ }
26
+ /** A part of the club's programs, such as Summer, with its revenue account. */
27
+ export interface ProgramArea {
28
+ id: Id;
29
+ name: string;
30
+ revenue_account_id: Id;
21
31
  }
22
32
  export interface Account {
23
33
  id: Id;
@@ -56,13 +66,24 @@ export interface Organization {
56
66
  fiscal_year_start_month: number;
57
67
  }
58
68
  /** A cost the engine calculates. The organization sets which account each is recorded in. */
59
- export type CostType = "coach_wages" | "salaries" | "payroll_tax" | "ribs" | "boat_maintenance" | "resources" | "travel" | "cogs" | "card_fees";
69
+ export type CostType = "coach_wages" | "salaries" | "payroll_tax" | "ribs" | "boat_maintenance" | "resources" | "travel" | "cogs";
60
70
  export interface OrganizationCostAccount {
61
71
  id: Id;
62
72
  organization_id: Id;
63
73
  cost_type: CostType;
64
74
  account_id: Id;
65
75
  }
76
+ /** A plan: a program area's for one year, or the organization's Operations Plan. */
77
+ export interface Plan {
78
+ id: Id;
79
+ kind: "program_area" | "operations";
80
+ program_area_id: Id | null;
81
+ }
82
+ /** A plan version. The engine reads only which plan it belongs to. */
83
+ export interface PlanVersion {
84
+ id: Id;
85
+ plan_id: Id;
86
+ }
66
87
  export type ActivityKind = "practice" | "regatta" | "clinic" | "travel";
67
88
  export interface PlanOffering extends Versioned {
68
89
  product_id: Id;
@@ -152,8 +173,6 @@ export interface PlanBudgetLine extends Versioned {
152
173
  export interface PlanRates extends Versioned {
153
174
  /** Employer payroll costs, as a share of wages. */
154
175
  payroll_tax_rate: number | null;
155
- /** Card processing fees, as a share of revenue. */
156
- card_fee_rate: number | null;
157
176
  lodging_per_night_cents: number | null;
158
177
  mileage_rate_cents: number | null;
159
178
  }
@@ -174,9 +193,11 @@ export interface PlanSalariedPosition extends Versioned {
174
193
  remainder_pool_id: Id | null;
175
194
  form_990_line: string | null;
176
195
  }
196
+ /** Hours charged to a product, or to a program area and shared among its offerings: one of the two. */
177
197
  export interface PlanSalariedBooking extends Versioned {
178
198
  position_id: Id;
179
- product_id: Id;
199
+ product_id: Id | null;
200
+ program_area_id: Id | null;
180
201
  kind: "management";
181
202
  hours: number;
182
203
  }
@@ -220,6 +241,18 @@ export interface PlanBonusOwnerProduct extends Versioned {
220
241
  bonus_owner_id: Id;
221
242
  product_id: Id;
222
243
  }
244
+ /** A charge set as a share of each offering's revenue, such as scholarships or card fees. */
245
+ export interface PlanRevenueCharge extends Versioned {
246
+ name: string;
247
+ /** A share, such as 0.02 for 2%. */
248
+ rate: number;
249
+ /** The kinds of product it applies to. */
250
+ product_kinds: ProductKind[] | null;
251
+ /** A revenue account adds revenue; an expense account adds a cost. */
252
+ account_id: Id;
253
+ excluded_from_mtdc: boolean;
254
+ note: string | null;
255
+ }
223
256
  /** The catalog plus every row of the plan versions being calculated. */
224
257
  export interface PlanData {
225
258
  /** The plan year. Fiscal year N ends in calendar year N. */
@@ -229,11 +262,15 @@ export interface PlanData {
229
262
  /** The resource kind whose `plan_resources` price RIB hours. */
230
263
  rib_resource_kind_id: Id | null;
231
264
  products: Product[];
265
+ program_areas: ProgramArea[];
232
266
  accounts: Account[];
233
267
  qualifications: Qualification[];
234
268
  qualification_covers: QualificationCover[];
235
269
  boat_classes: BoatClass[];
236
270
  resource_kinds: ResourceKind[];
271
+ /** The plans and versions being calculated, so the engine can tell which plan each offering is in. */
272
+ plans: Plan[];
273
+ plan_versions: PlanVersion[];
237
274
  plan_offerings: PlanOffering[];
238
275
  plan_offering_boat_classes: PlanOfferingBoatClass[];
239
276
  plan_trips: PlanTrip[];
@@ -242,6 +279,7 @@ export interface PlanData {
242
279
  plan_labor_lines: PlanLaborLine[];
243
280
  plan_budget_lines: PlanBudgetLine[];
244
281
  plan_rates: PlanRates[];
282
+ plan_revenue_charges: PlanRevenueCharge[];
245
283
  plan_staff_levels: PlanStaffLevel[];
246
284
  plan_salaried_positions: PlanSalariedPosition[];
247
285
  plan_salaried_bookings: PlanSalariedBooking[];
@@ -269,10 +307,10 @@ export interface Totals {
269
307
  expense_cents: number;
270
308
  net_cents: number;
271
309
  }
272
- /** One offering's P&L for the plan year, or a product's, which adds up its offerings. */
310
+ /** One offering's P&L for the plan year, or a product's or program area's, which adds up its offerings. */
273
311
  export interface Pnl extends Totals {
274
- level: "offering" | "product";
275
- /** An offering key, or a product id. */
312
+ level: "offering" | "product" | "program_area";
313
+ /** An offering key, a product id, or a program area id. */
276
314
  key: Id;
277
315
  /** Each account's total, revenue and expense alike. */
278
316
  by_account: {
@@ -309,10 +347,23 @@ export interface EngineWarning {
309
347
  /** The key of the row it's about, when there is one. */
310
348
  key?: string;
311
349
  }
350
+ /**
351
+ * The organization's year: operating revenue pays for operating costs, and fundraising for capital.
352
+ * Capital is buying something lasting, on an asset account, and never reaches a program area.
353
+ */
354
+ export interface OrganizationTotals {
355
+ operating: Totals;
356
+ fundraising: Totals;
357
+ /** Spent on capital, positive. */
358
+ capital_cents: number;
359
+ /** Operating net, plus fundraising net, less capital. */
360
+ total_cents: number;
361
+ }
312
362
  export interface EngineOutput {
313
363
  lines: CostLine[];
314
- /** Each offering's P&L, then each product's. */
364
+ /** Each offering's P&L, then each product's, then each program area's. */
315
365
  pnl: Pnl[];
366
+ organization: OrganizationTotals;
316
367
  activities: ComputedActivity[];
317
368
  warnings: EngineWarning[];
318
369
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cyc-seattle/platform",
3
- "version": "1.15.0",
3
+ "version": "1.16.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",