@cyc-seattle/platform 1.14.1 → 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.
- package/dist/planning/context.d.ts +6 -1
- package/dist/planning/context.js +22 -1
- package/dist/planning/engine.js +93 -3
- package/dist/planning/lines.d.ts +7 -3
- package/dist/planning/lines.js +26 -13
- package/dist/planning/measures.d.ts +5 -2
- package/dist/planning/measures.js +8 -3
- package/dist/planning/revenue.d.ts +5 -1
- package/dist/planning/revenue.js +14 -9
- package/dist/planning/salaried.js +16 -6
- package/dist/planning/types.d.ts +61 -10
- package/package.json +1 -1
|
@@ -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. */
|
package/dist/planning/context.js
CHANGED
|
@@ -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"
|
|
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);
|
package/dist/planning/engine.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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)
|
package/dist/planning/lines.d.ts
CHANGED
|
@@ -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
|
|
5
|
-
* by MTDC, so every offering's own costs are in first, whatever order the lines
|
|
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
|
-
/**
|
|
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;
|
package/dist/planning/lines.js
CHANGED
|
@@ -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
|
|
5
|
-
* by MTDC, so every offering's own costs are in first, whatever order the lines
|
|
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(
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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:
|
|
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
|
-
/**
|
|
7
|
-
|
|
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
|
-
/**
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
/**
|
|
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;
|
package/dist/planning/revenue.js
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
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(
|
|
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
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
.
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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);
|
package/dist/planning/types.d.ts
CHANGED
|
@@ -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:
|
|
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"
|
|
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
|
|
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.
|
|
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",
|