@cyc-seattle/platform 1.50.0 → 1.52.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -101,20 +101,20 @@ deployer needs first.
101
101
  authenticates with Workload Identity Federation and stores no credentials. Each job runs in a
102
102
  GitHub environment that holds its identity and state bucket:
103
103
 
104
- | Event | Jobs, in order |
105
- | -------------------------- | ---------------------------------------------------------------------------------------------- |
106
- | Pull request | `ci` |
107
- | Merge queue | `ci`, then `dev`: `mise run deploy` as `deploy-dev`, then `mise run integration`; `dev-passed` |
108
- | Push to `main` | `prod-preview`, then `prod`, which waits for the `maintainers` approval |
109
- | Run by hand, from a branch | `ci`, then `dev` |
110
- | Run by hand, from `main` | `prod-preview`, then `prod` |
111
-
112
- Dev is shared, so only the merge queue deploys to it (#69, #80). PRs queued together deploy and
113
- test as one batch: each queued PR's run skips dev when a newer PR is queued behind it, and the newest
114
- tests them all. The queue requires `dev-passed`, so a batch merges only once its dev deploy and the
115
- integration tests pass, and `main` gets the exact commit the queue tested, in one push. If the
116
- newest PR fails, the queue drops it and the rest of the batch deploys without it. A queued PR of only docs or CI skips `dev`. To try a
117
- branch on dev before queueing it, run `gh workflow run pull-request.yml --ref <branch>`.
104
+ | Event | Jobs, in order |
105
+ | -------------------------- | ----------------------------------------------------------------------- |
106
+ | Pull request | `ci` |
107
+ | Merge queue | `ci`, then `dev`: `mise run deploy` as `deploy-dev`; `dev-passed` |
108
+ | Push to `main` | `prod-preview`, then `prod`, which waits for the `maintainers` approval |
109
+ | Run by hand, from a branch | `ci`, then `dev` |
110
+ | Run by hand, from `main` | `prod-preview`, then `prod` |
111
+
112
+ Dev is shared, so only the merge queue deploys to it (#69, #80). PRs queued together deploy as
113
+ one batch: each queued PR's run skips dev when a newer PR is queued behind it, and the newest
114
+ deploys them all. The queue requires `dev-passed`, so a batch merges only once its dev deploy passes, and `main` gets the exact commit the queue tested, in one push. If the
115
+ newest PR fails, the queue drops it and the rest of the batch deploys without it. A queued PR of only docs or CI skips `dev`. The queue
116
+ doesn't run the integration tests (#161); a run by hand does. To try a
117
+ branch on dev and test it before queueing it, run `gh workflow run pull-request.yml --ref <branch>`.
118
118
 
119
119
  `prod-preview` runs `mise run preview` as `deploy-runner` and writes the diff to the job summary.
120
120
  `prod` runs `mise run deploy` on the same commit once approved. It runs only when the preview shows
@@ -1,10 +1,12 @@
1
1
  import type { Context } from "./context.ts";
2
- import type { Id } from "./types.ts";
2
+ import { type OfferingTerms } from "./terms.ts";
3
+ import type { BonusSummary } from "./types.ts";
3
4
  /**
4
- * Records each bonus owner's `bonus_rate` × its products' net for the year, above zero, after the
5
- * pools or before them. Every base is worked out before any bonus is booked, so one bonus never
6
- * counts in another's base. Each bonus is divided among the owned offerings by revenue, with the
7
- * year's payroll tax on it, and neither is counted in MTDC. Returns each position's bonus, by
8
- * position key.
5
+ * Records each bonus owner's `bonus_rate` × its products' net in each term, above zero, after the
6
+ * pools or before them. A term's loss doesn't lower another term's bonus. Offerings in no term, and
7
+ * anything not on an offering, make one more base, for the year. Every base is worked out before any
8
+ * bonus is booked, so one bonus never counts in another's base. Each bonus is divided among that
9
+ * base's offerings by revenue, with the year's payroll tax on it, and neither is counted in MTDC.
10
+ * Returns each bonus, by owner and term.
9
11
  */
10
- export declare function bookBonuses(ctx: Context): Map<Id, number>;
12
+ export declare function bookBonuses(ctx: Context, terms: OfferingTerms): BonusSummary[];
@@ -1,77 +1,99 @@
1
1
  import { revenue } from "./measures.js";
2
+ import { compareTerms } from "./terms.js";
2
3
  /**
3
- * Records each bonus owner's `bonus_rate` × its products' net for the year, above zero, after the
4
- * pools or before them. Every base is worked out before any bonus is booked, so one bonus never
5
- * counts in another's base. Each bonus is divided among the owned offerings by revenue, with the
6
- * year's payroll tax on it, and neither is counted in MTDC. Returns each position's bonus, by
7
- * position key.
4
+ * Records each bonus owner's `bonus_rate` × its products' net in each term, above zero, after the
5
+ * pools or before them. A term's loss doesn't lower another term's bonus. Offerings in no term, and
6
+ * anything not on an offering, make one more base, for the year. Every base is worked out before any
7
+ * bonus is booked, so one bonus never counts in another's base. Each bonus is divided among that
8
+ * base's offerings by revenue, with the year's payroll tax on it, and neither is counted in MTDC.
9
+ * Returns each bonus, by owner and term.
8
10
  */
9
- export function bookBonuses(ctx) {
10
- const bonuses = ctx.data.plan_bonus_owners.map((owner) => ({ owner, base: baseOf(ctx, owner) }));
11
+ export function bookBonuses(ctx, terms) {
12
+ const bonuses = ctx.data.plan_bonus_owners.map((owner) => ({ owner, bases: basesOf(ctx, owner, terms) }));
11
13
  const taxRate = ctx.rates?.payroll_tax_rate ?? 0;
12
14
  const taxAccount = taxRate > 0 && bonuses.length > 0 ? ctx.costAccount("payroll_tax") : undefined;
13
- const byPosition = new Map();
14
- for (const { owner, base } of bonuses) {
15
- const bonus = owner.bonus_rate * Math.max(0, base.net);
16
- if (bonus === 0)
17
- continue;
18
- const weights = new Map();
19
- for (const key of base.offeringKeys) {
20
- const r = revenue(ctx, key);
21
- if (r > 0)
22
- weights.set(key, r);
23
- }
24
- if (weights.size === 0) {
25
- if (base.offeringKeys.length === 0) {
26
- ctx.warn("bonus_without_offerings", `${owner.role_name}'s bonus has no offerings this year to charge it to, so it's left out`, owner.key);
27
- continue;
28
- }
29
- // Its net came from elsewhere, such as revenue booked on a product, so its offerings share it evenly.
30
- ctx.warn("bonus_without_revenue", `${owner.role_name}'s offerings have no revenue of their own, so its bonus is split evenly among them`, owner.key);
31
- for (const key of base.offeringKeys)
32
- weights.set(key, 1);
33
- }
34
- const total = [...weights.values()].reduce((t, w) => t + w, 0);
35
- if (owner.position_key != null) {
36
- byPosition.set(owner.position_key, (byPosition.get(owner.position_key) ?? 0) + bonus);
37
- }
38
- for (const [key, w] of weights) {
39
- const amount = (bonus * w) / total;
40
- ctx.book({
41
- cost_center_type: "offering",
42
- cost_center_key: key,
43
- account_id: owner.account_id,
44
- function: ctx.functionFor(owner.account_id, null, "offering"),
45
- amount,
46
- line_key: `bonus:${owner.key}`,
47
- line_name: `Bonus: ${owner.role_name}`,
48
- explanation: `${owner.role_name}: ${owner.bonus_rate} × net of its products`,
49
- stage: "bonus",
50
- mtdc_share: 0,
51
- });
52
- if (taxAccount === undefined)
53
- continue;
54
- ctx.book({
55
- cost_center_type: "offering",
56
- cost_center_key: key,
57
- account_id: taxAccount,
58
- function: ctx.functionFor(owner.account_id, null, "offering"),
59
- amount: amount * taxRate,
60
- line_key: `bonus_payroll_tax:${owner.key}`,
61
- line_name: `Payroll tax on bonus: ${owner.role_name}`,
62
- explanation: `${taxRate} × ${owner.role_name}'s bonus`,
63
- stage: "bonus",
64
- mtdc_share: 0,
15
+ const summaries = [];
16
+ for (const { owner, bases } of bonuses) {
17
+ const area = areaOf(ctx, owner);
18
+ for (const base of bases) {
19
+ const bonus = owner.bonus_rate * Math.max(0, base.net);
20
+ const booked = bonus === 0 ? 0 : bookBonus(ctx, owner, base, bonus, taxRate, taxAccount);
21
+ summaries.push({
22
+ bonus_owner_key: owner.key,
23
+ role_name: owner.role_name,
24
+ position_key: owner.position_key ?? null,
25
+ program_area_id: area,
26
+ term_key: base.term?.key ?? null,
27
+ bonus_rate: owner.bonus_rate,
28
+ net_cents: Math.round(base.net),
29
+ bonus_cents: Math.round(booked),
65
30
  });
66
31
  }
67
32
  }
68
- return byPosition;
33
+ return summaries;
34
+ }
35
+ /** Books one term's bonus on its offerings, by revenue. Returns what it booked. */
36
+ function bookBonus(ctx, owner, base, bonus, taxRate, taxAccount) {
37
+ const weights = new Map();
38
+ for (const key of base.offeringKeys) {
39
+ const r = revenue(ctx, key);
40
+ if (r > 0)
41
+ weights.set(key, r);
42
+ }
43
+ if (weights.size === 0) {
44
+ if (base.offeringKeys.length === 0) {
45
+ ctx.warn("bonus_without_offerings", `${owner.role_name}'s bonus has no offerings this year to charge it to, so it's left out`, owner.key);
46
+ return 0;
47
+ }
48
+ // Its net came from elsewhere, such as revenue booked on a product, so its offerings share it evenly.
49
+ ctx.warn("bonus_without_revenue", `${owner.role_name}'s offerings have no revenue of their own, so its bonus is split evenly among them`, owner.key);
50
+ for (const key of base.offeringKeys)
51
+ weights.set(key, 1);
52
+ }
53
+ const total = [...weights.values()].reduce((t, w) => t + w, 0);
54
+ for (const [key, w] of weights) {
55
+ const amount = (bonus * w) / total;
56
+ ctx.book({
57
+ cost_center_type: "offering",
58
+ cost_center_key: key,
59
+ account_id: owner.account_id,
60
+ function: ctx.functionFor(owner.account_id, null, "offering"),
61
+ amount,
62
+ line_key: `bonus:${owner.key}`,
63
+ line_name: `Bonus: ${owner.role_name}`,
64
+ explanation: `${owner.role_name}: ${owner.bonus_rate} × net of its products ${base.term === null ? "in no term" : `in ${base.term.name}`}`,
65
+ stage: "bonus",
66
+ mtdc_share: 0,
67
+ });
68
+ if (taxAccount === undefined)
69
+ continue;
70
+ ctx.book({
71
+ cost_center_type: "offering",
72
+ cost_center_key: key,
73
+ account_id: taxAccount,
74
+ function: ctx.functionFor(owner.account_id, null, "offering"),
75
+ amount: amount * taxRate,
76
+ line_key: `bonus_payroll_tax:${owner.key}`,
77
+ line_name: `Payroll tax on bonus: ${owner.role_name}`,
78
+ explanation: `${taxRate} × ${owner.role_name}'s bonus`,
79
+ stage: "bonus",
80
+ mtdc_share: 0,
81
+ });
82
+ }
83
+ return bonus;
84
+ }
85
+ /** The program area of all the owner's products, or null when they're in more than one or none. */
86
+ function areaOf(ctx, owner) {
87
+ const areas = new Set(ctx.data.plan_bonus_owner_products
88
+ .filter((row) => row.bonus_owner_id === owner.id)
89
+ .map((row) => ctx.products.get(row.product_id)?.program_area_id ?? null));
90
+ return areas.size === 1 ? ([...areas][0] ?? null) : null;
69
91
  }
70
92
  /**
71
- * The owner's products' net for the year, and their offerings. An area plan's bonus counts only that
72
- * area's products, and warns about any other.
93
+ * The owner's products' net in each term they have an offering in, with those offerings, in term
94
+ * order and then no term. An area plan's bonus counts only that area's products, and warns about any other.
73
95
  */
74
- function baseOf(ctx, owner) {
96
+ function basesOf(ctx, owner, terms) {
75
97
  const plan = ctx.planOfVersion.get(owner.version_id);
76
98
  const products = new Set();
77
99
  for (const row of ctx.data.plan_bonus_owner_products) {
@@ -84,21 +106,37 @@ function baseOf(ctx, owner) {
84
106
  }
85
107
  products.add(row.product_id);
86
108
  }
87
- const offeringKeys = new Set(ctx.data.plan_offerings.filter((o) => products.has(o.product_id)).map((o) => o.key));
109
+ const bases = new Map();
110
+ const baseFor = (term) => {
111
+ const base = bases.get(term?.key ?? null) ?? { term, net: 0, offeringKeys: [] };
112
+ bases.set(term?.key ?? null, base);
113
+ return base;
114
+ };
115
+ const owned = new Set();
116
+ for (const o of ctx.data.plan_offerings) {
117
+ if (!products.has(o.product_id))
118
+ continue;
119
+ owned.add(o.key);
120
+ baseFor(terms.get(o.key) ?? null).offeringKeys.push(o.key);
121
+ }
88
122
  const afterPools = (owner.basis ?? "after_shared_costs") === "after_shared_costs";
89
- let net = 0;
90
123
  for (const e of ctx.entries) {
91
124
  if (e.stage === "bonus" || (!afterPools && e.stage === "pool") || e.cost_center_key == null)
92
125
  continue;
93
- const owned = (e.cost_center_type === "offering" && offeringKeys.has(e.cost_center_key)) ||
94
- (e.cost_center_type === "product" && products.has(e.cost_center_key));
95
- if (!owned)
126
+ let term;
127
+ if (e.cost_center_type === "offering" && owned.has(e.cost_center_key)) {
128
+ term = terms.get(e.cost_center_key) ?? null;
129
+ }
130
+ else if (e.cost_center_type === "product" && products.has(e.cost_center_key)) {
131
+ term = null;
132
+ }
133
+ else
96
134
  continue;
97
135
  const type = ctx.accountType(e.account_id);
98
136
  if (type === "revenue")
99
- net += e.amount;
137
+ baseFor(term).net += e.amount;
100
138
  else if (type === "expense")
101
- net -= e.amount;
139
+ baseFor(term).net -= e.amount;
102
140
  }
103
- return { net, offeringKeys: [...offeringKeys] };
141
+ return [...bases.values()].sort((a, b) => compareTerms(a.term, b.term));
104
142
  }
@@ -8,6 +8,7 @@ import { bookPayrollTax } from "./payroll.js";
8
8
  import { allocatePools } from "./pools.js";
9
9
  import { bookRevenue, countDays } from "./revenue.js";
10
10
  import { bookSalaries } from "./salaried.js";
11
+ import { compareTerms, offeringTerms } from "./terms.js";
11
12
  import { enrollmentOf, round2, Staffing } from "./staffing.js";
12
13
  /**
13
14
  * Calculates the plan year's P&L from a plan's rows. The steps run in the order
@@ -18,6 +19,7 @@ export function computePlan(data) {
18
19
  for (const o of data.plan_offerings)
19
20
  ctx.enrollment.set(o.id, enrollmentOf(o));
20
21
  checkAreas(ctx);
22
+ const terms = offeringTerms(ctx);
21
23
  const { activities, coaching, doubleBooked } = new Staffing(ctx).run(scheduleActivities(ctx));
22
24
  countDays(ctx, activities);
23
25
  bookRevenue(ctx);
@@ -30,15 +32,22 @@ export function computePlan(data) {
30
32
  bookPayrollTax(ctx);
31
33
  const mtdcByOffering = new Map(ctx.data.plan_offerings.map((o) => [o.key, mtdc(ctx, o.key)]));
32
34
  allocatePools(ctx);
33
- const bonuses = bookBonuses(ctx);
35
+ const bonuses = bookBonuses(ctx, terms);
36
+ const bonusByPosition = new Map();
37
+ for (const b of bonuses) {
38
+ if (b.position_key == null)
39
+ continue;
40
+ bonusByPosition.set(b.position_key, (bonusByPosition.get(b.position_key) ?? 0) + b.bonus_cents);
41
+ }
34
42
  const lines = sumLines(ctx);
35
43
  const organization = organizationTotals(ctx, lines);
36
44
  return {
37
45
  lines,
38
- pnl: summarize(ctx, lines, mtdcByOffering),
46
+ pnl: summarize(ctx, lines, mtdcByOffering, terms),
39
47
  organization,
40
48
  activities,
41
- positions: summarizePositions(ctx, coaching, charged, bonuses, doubleBooked),
49
+ positions: summarizePositions(ctx, coaching, charged, bonusByPosition, doubleBooked),
50
+ bonuses,
42
51
  capital: rankCapital(ctx, lines, organization),
43
52
  warnings: ctx.warnings,
44
53
  };
@@ -173,8 +182,8 @@ function sumLines(ctx) {
173
182
  })
174
183
  .filter((l) => l.amount_cents !== 0);
175
184
  }
176
- /** Each plan-year offering's P&L, then each product's, then each program area's. */
177
- function summarize(ctx, lines, mtdcByOffering) {
185
+ /** Each plan-year offering's P&L, then each product's, then each program area's, then each area's terms. */
186
+ function summarize(ctx, lines, mtdcByOffering, terms) {
178
187
  const pnl = (level, key, own, counts) => {
179
188
  const byAccount = new Map();
180
189
  let revenue = 0;
@@ -231,24 +240,38 @@ function summarize(ctx, lines, mtdcByOffering) {
231
240
  for (const l of lines)
232
241
  if (l.cost_center_type === "program_area" && l.cost_center_key !== null)
233
242
  areaIds.add(l.cost_center_key);
234
- const areaPnl = [...areaIds].sort().map((id) => {
243
+ const areaPnl = [];
244
+ const termPnl = [];
245
+ for (const id of [...areaIds].sort()) {
235
246
  const own = offeringPnl.filter((s) => {
236
247
  const product = ctx.offeringsByKey.get(s.key)?.product_id;
237
248
  return product !== undefined && ctx.products.get(product)?.program_area_id === id;
238
249
  });
239
- const keys = new Set(own.map((s) => s.key));
240
250
  const products = new Set(own.map((s) => ctx.offeringsByKey.get(s.key)?.product_id));
241
- const add = (field) => own.reduce((t, s) => t + s[field], 0);
242
- return pnl("program_area", id, lines.filter((l) => (l.cost_center_type === "offering" && l.cost_center_key !== null && keys.has(l.cost_center_key)) ||
243
- (l.cost_center_type === "product" && products.has(l.cost_center_key ?? undefined)) ||
244
- (l.cost_center_type === "program_area" && l.cost_center_key === id)), {
245
- mtdc_cents: add("mtdc_cents"),
246
- enrollment: add("enrollment"),
247
- days: add("days"),
248
- sailor_days: add("sailor_days"),
249
- });
250
- });
251
- return [...offeringPnl, ...productPnl, ...areaPnl];
251
+ // Costs left on the area or a product, because it had no offerings to divide them among, are in no term.
252
+ const rollup = (level, offerings, leftovers) => {
253
+ const keys = new Set(offerings.map((s) => s.key));
254
+ const add = (field) => offerings.reduce((t, s) => t + s[field], 0);
255
+ return pnl(level, id, lines.filter((l) => (l.cost_center_type === "offering" && l.cost_center_key !== null && keys.has(l.cost_center_key)) ||
256
+ (leftovers && l.cost_center_type === "product" && products.has(l.cost_center_key ?? undefined)) ||
257
+ (leftovers && l.cost_center_type === "program_area" && l.cost_center_key === id)), {
258
+ mtdc_cents: add("mtdc_cents"),
259
+ enrollment: add("enrollment"),
260
+ days: add("days"),
261
+ sailor_days: add("sailor_days"),
262
+ });
263
+ };
264
+ areaPnl.push(rollup("program_area", own, true));
265
+ const termOf = (s) => terms.get(s.key) ?? null;
266
+ const areaTerms = [...new Map(own.map((s) => [termOf(s)?.key, termOf(s)])).values()].filter((t) => t !== null);
267
+ for (const term of [...areaTerms.sort(compareTerms), null]) {
268
+ const inTerm = own.filter((s) => termOf(s)?.key === term?.key);
269
+ const row = { ...rollup("program_area_term", inTerm, term === null), term_key: term?.key ?? null };
270
+ if (inTerm.length > 0 || row.by_account.length > 0)
271
+ termPnl.push(row);
272
+ }
273
+ }
274
+ return [...offeringPnl, ...productPnl, ...areaPnl, ...termPnl];
252
275
  }
253
276
  function divide(totals, by) {
254
277
  if (by <= 0)
@@ -79,7 +79,7 @@ export function bookBudgetLines(ctx, booker) {
79
79
  line_key: `budget:${line.key}`,
80
80
  line_name: line.name,
81
81
  explanation: line.name,
82
- mtdc_share: line.excluded_from_mtdc ? 0 : 1,
82
+ mtdc_share: line.in_mtdc ? 1 : 0,
83
83
  });
84
84
  }
85
85
  }
@@ -51,7 +51,7 @@ export function bookRevenue(ctx) {
51
51
  for (const charge of ctx.data.plan_revenue_charges) {
52
52
  if (!(charge.product_kinds ?? []).includes(product.kind))
53
53
  continue;
54
- book(charge.account_id, total * charge.rate, `charge:${charge.key}`, charge.name, `${charge.name}: ${charge.rate} × revenue`, charge.excluded_from_mtdc ? 0 : 1);
54
+ book(charge.account_id, total * charge.rate, `charge:${charge.key}`, charge.name, `${charge.name}: ${charge.rate} × revenue`, charge.in_mtdc ? 1 : 0);
55
55
  }
56
56
  }
57
57
  }
@@ -0,0 +1,11 @@
1
+ import type { Context } from "./context.ts";
2
+ import type { Id, PlanTerm } from "./types.ts";
3
+ /** Each offering's term, by offering key, in no particular order. Null for an offering in none. */
4
+ export type OfferingTerms = Map<Id, PlanTerm | null>;
5
+ /**
6
+ * Finds each offering's term in the plan's terms. An offering whose term isn't there, such as one deleted
7
+ * from the Operations Plan, is in none, with a warning.
8
+ */
9
+ export declare function offeringTerms(ctx: Context): OfferingTerms;
10
+ /** Orders terms by their first days, then names, with no term (null) last. */
11
+ export declare function compareTerms(a: PlanTerm | null, b: PlanTerm | null): number;
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Finds each offering's term in the plan's terms. An offering whose term isn't there, such as one deleted
3
+ * from the Operations Plan, is in none, with a warning.
4
+ */
5
+ export function offeringTerms(ctx) {
6
+ const terms = new Map((ctx.data.plan_terms ?? []).map((t) => [t.key, t]));
7
+ const byOffering = new Map();
8
+ for (const o of ctx.data.plan_offerings) {
9
+ const term = o.term_key == null ? null : (terms.get(o.term_key) ?? null);
10
+ if (o.term_key != null && term === null) {
11
+ ctx.warn("unknown_term", `${o.name} is in a term the Operations Plan doesn't have, so it counts as in no term`, o.key);
12
+ }
13
+ byOffering.set(o.key, term);
14
+ }
15
+ return byOffering;
16
+ }
17
+ /** Orders terms by their first days, then names, with no term (null) last. */
18
+ export function compareTerms(a, b) {
19
+ if (a === null || b === null)
20
+ return a === b ? 0 : a === null ? 1 : -1;
21
+ return a.starts_on.localeCompare(b.starts_on) || a.name.localeCompare(b.name) || a.key.localeCompare(b.key);
22
+ }
@@ -192,8 +192,8 @@ export interface PlanBudgetLine extends Versioned {
192
192
  cost_center_key: Id | null;
193
193
  amount_cents: number;
194
194
  fiscal_year: number;
195
- /** Set for a cost 2 CFR 200.1 leaves out of MTDC, such as equipment or participant support. */
196
- excluded_from_mtdc: boolean;
195
+ /** Whether it counts toward MTDC; cleared for a cost 2 CFR 200.1 leaves out, such as equipment or participant support. */
196
+ in_mtdc: boolean;
197
197
  note: string | null;
198
198
  }
199
199
  /**
@@ -274,7 +274,8 @@ export interface PlanRevenueCharge extends Versioned {
274
274
  product_kinds: ProductKind[] | null;
275
275
  /** A revenue account adds revenue; an expense account adds a cost. */
276
276
  account_id: Id;
277
- excluded_from_mtdc: boolean;
277
+ /** Whether it counts toward MTDC; cleared for one 2 CFR 200.1 leaves out, such as scholarships. */
278
+ in_mtdc: boolean;
278
279
  note: string | null;
279
280
  }
280
281
  /** A day the organization is closed, such as Labor Day. */
@@ -349,9 +350,12 @@ export interface Totals {
349
350
  }
350
351
  /** One offering's P&L for the plan year, or a product's or program area's, which adds up its offerings. */
351
352
  export interface Pnl extends Totals {
352
- level: "offering" | "product" | "program_area";
353
+ /** `program_area_term` is a program area's offerings in one term, or in none. */
354
+ level: "offering" | "product" | "program_area" | "program_area_term";
353
355
  /** An offering key, a product id, or a program area id. */
354
356
  key: Id;
357
+ /** A `program_area_term`'s `plan_terms` key, null for its offerings in no term. Only that level has one. */
358
+ term_key?: Id | null;
355
359
  /** Each account's total, revenue and expense alike. */
356
360
  by_account: {
357
361
  account_id: Id;
@@ -447,10 +451,26 @@ export interface PositionSummary {
447
451
  /** Dates it's on two or more groups at once. */
448
452
  double_booked_dates: number;
449
453
  }
454
+ /** A bonus owner's bonus for one term, or on its offerings in no term for the year. */
455
+ export interface BonusSummary {
456
+ bonus_owner_key: Id;
457
+ role_name: string;
458
+ position_key: Id | null;
459
+ /** The program area of all the owner's products, or null when they're in more than one. */
460
+ program_area_id: Id | null;
461
+ /** A `plan_terms` key, or null for its offerings in no term. */
462
+ term_key: Id | null;
463
+ bonus_rate: number;
464
+ /** The net the bonus is figured on, which can be below zero. */
465
+ net_cents: number;
466
+ bonus_cents: number;
467
+ }
450
468
  export interface EngineOutput {
451
469
  lines: CostLine[];
452
- /** Each offering's P&L, then each product's, then each program area's. */
470
+ /** Each offering's P&L, then each product's, then each program area's, then each area's terms. */
453
471
  pnl: Pnl[];
472
+ /** Each bonus owner's bonus in each term it has offerings in, then in no term. */
473
+ bonuses: BonusSummary[];
454
474
  organization: OrganizationTotals;
455
475
  activities: ComputedActivity[];
456
476
  /** Each salaried position's pay for the year. */
@@ -1,6 +1,7 @@
1
1
  /** Every collection, for `createDirectus<Schema>()`. */
2
2
  export interface Schema {
3
3
  accounts: Accounts[];
4
+ announcements: Announcements[];
4
5
  assets: Assets[];
5
6
  audit_findings: AuditFindings[];
6
7
  boat_classes: BoatClasses[];
@@ -26,6 +27,7 @@ export interface Schema {
26
27
  group_policies: GroupPolicies[];
27
28
  groups: Groups[];
28
29
  households: Households[];
30
+ links: Links[];
29
31
  medical_profiles: MedicalProfiles[];
30
32
  offerings: Offerings[];
31
33
  organization_cost_accounts: OrganizationCostAccounts[];
@@ -46,6 +48,7 @@ export interface Schema {
46
48
  plan_pools: PlanPools[];
47
49
  plan_programs: PlanPrograms[];
48
50
  plan_rates: PlanRates[];
51
+ plan_result_bonuses: PlanResultBonuses[];
49
52
  plan_result_capital: PlanResultCapital[];
50
53
  plan_result_lines: PlanResultLines[];
51
54
  plan_result_pnl: PlanResultPnl[];
@@ -113,6 +116,14 @@ export interface Accounts {
113
116
  /** Kept for history, and closed to new lines. */
114
117
  retired: boolean;
115
118
  }
119
+ /** What Sail Team's home page shows, newest first. */
120
+ export interface Announcements {
121
+ id: string;
122
+ title: string;
123
+ body: string;
124
+ /** The date the home page shows. Newest comes first. */
125
+ published_on: string;
126
+ }
116
127
  /** Each boat, motor and piece of equipment CSC owns: sailboats, powerboats such as the coach boats, motors, and others such as trailers. Planning counts the boats of each class CSC has on each day. */
117
128
  export interface Assets {
118
129
  id: string;
@@ -445,6 +456,24 @@ export interface Households {
445
456
  share_contact: boolean | null;
446
457
  date_created: string | null;
447
458
  }
459
+ /** The Links page in Sail Team. Drag the rows to change their order. */
460
+ export interface Links {
461
+ id: string;
462
+ title: string;
463
+ /** The page's address, starting with https://. */
464
+ url: string;
465
+ /** A line under the title, if it needs one. */
466
+ note: string | null;
467
+ sort: number | null;
468
+ /** The page's own title, read from the page when the link is saved. */
469
+ preview_title: string | null;
470
+ /** The page's own summary, read from the page when the link is saved. */
471
+ preview_description: string | null;
472
+ /** The address of the page's own picture, read from the page when the link is saved. */
473
+ preview_image: string | null;
474
+ /** When the page was last read. Empty if it couldn't be read: save the link again to retry. */
475
+ previewed_at: string | null;
476
+ }
448
477
  /** A person's medical information, kept apart from People so only some staff see it. */
449
478
  export interface MedicalProfiles {
450
479
  id: string;
@@ -652,8 +681,8 @@ export interface PlanBudgetLines {
652
681
  /** In cents: 12500 is $125.00. */
653
682
  amount_cents: number;
654
683
  fiscal_year: number;
655
- /** Set for a cost left out of modified total direct costs (MTDC), such as equipment. */
656
- excluded_from_mtdc: boolean;
684
+ /** Counts toward modified total direct costs (MTDC). Clear it for a cost 2 CFR 200.1 leaves out, such as equipment. */
685
+ in_mtdc: boolean;
657
686
  note: string | null;
658
687
  }
659
688
  /** Something lasting CSC means to buy, ranked with the most needed first. */
@@ -768,6 +797,29 @@ export interface PlanRates {
768
797
  /** In cents: 12500 is $125.00. */
769
798
  mileage_rate_cents: number | null;
770
799
  }
800
+ /** Each bonus in a Financial Plan, for each term, and for the offerings in no term on the year. 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. */
801
+ export interface PlanResultBonuses {
802
+ id: string;
803
+ /** The bonus's role, such as Director. */
804
+ name: string;
805
+ term_key: string | null;
806
+ /** The term it's paid on. Empty for the offerings in no term, such as Adult Learn to Sail, whose bonus is worked out on the year. */
807
+ term_name: string | null;
808
+ /** The term's first day, which puts the terms in order. */
809
+ term_starts_on: string | null;
810
+ /** A share of the net. 0.05 means 5%. */
811
+ bonus_rate: string;
812
+ /** The net of its products in the term, which the bonus is figured on. In cents: 12500 is $125.00. */
813
+ net_cents: number;
814
+ /** Its rate × the net, or nothing when the net is a loss. In cents: 12500 is $125.00. */
815
+ bonus_cents: number;
816
+ bonus_owner_key: string;
817
+ program_area_key: string | null;
818
+ /** The Financial Plan these results are for. */
819
+ version_set_id: string | VersionSets;
820
+ /** The plan in the Financial Plan that pays the bonus. */
821
+ version_set_member_id: string | VersionSetMembers | null;
822
+ }
771
823
  /** 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. */
772
824
  export interface PlanResultCapital {
773
825
  id: string;
@@ -807,7 +859,7 @@ export interface PlanResultLines {
807
859
  /** A Financial Plan's P&L for each offering, product, and program area. 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. */
808
860
  export interface PlanResultPnl {
809
861
  id: string;
810
- level: "offering" | "product" | "program_area";
862
+ level: "offering" | "product" | "program_area" | "program_area_term";
811
863
  subject_key: string;
812
864
  name: string;
813
865
  /** In cents: 12500 is $125.00. */
@@ -825,6 +877,11 @@ export interface PlanResultPnl {
825
877
  version_set_id: string | VersionSets;
826
878
  /** The plan in the Financial Plan that holds what it's charged to. */
827
879
  version_set_member_id: string | VersionSetMembers | null;
880
+ term_key: string | null;
881
+ /** For a program area by term, the term. Empty for its offerings in no term, such as Adult Learn to Sail. */
882
+ term_name: string | null;
883
+ /** The term's first day, which puts the terms in order. */
884
+ term_starts_on: string | null;
828
885
  }
829
886
  /** Each salaried position's pay for the year, in a Financial Plan. 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. */
830
887
  export interface PlanResultPositions {
@@ -908,8 +965,8 @@ export interface PlanRevenueCharges {
908
965
  product_kinds: ("program" | "charter" | "storage_lease" | "merchandise" | "event_hosting" | "other")[] | null;
909
966
  /** A revenue account adds revenue, such as card fees families pay. An expense account adds a cost. */
910
967
  account_id: string | Accounts;
911
- /** Set for a charge 2 CFR 200.1 leaves out of MTDC, such as scholarships. */
912
- excluded_from_mtdc: boolean;
968
+ /** Counts toward MTDC. Clear it for a charge 2 CFR 200.1 leaves out, such as scholarships. */
969
+ in_mtdc: boolean;
913
970
  note: string | null;
914
971
  }
915
972
  /** Hours of a salaried position booked to a product. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cyc-seattle/platform",
3
- "version": "1.50.0",
3
+ "version": "1.52.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",