@arnaudjnn/billing-tools 15.23.0 → 16.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,145 @@
1
+ import { type PlanCatalog, type PlanModel } from "./plan-model.js";
2
+ /**
3
+ * The seat types a member can BE on, cheapest first.
4
+ *
5
+ * `shared` types are excluded: they are the pool an API caller or a guest draws from, not a
6
+ * rung a person climbs, and including them made "the next seat up" point at something no
7
+ * human can be assigned.
8
+ *
9
+ * This ordering — non-shared, by monthly price — is the definition of "better seat" used by
10
+ * every function below. It is exported because a UI that renders the ladder has to sort it
11
+ * the same way, and the only alternative to publishing the rule is each caller guessing it.
12
+ */
13
+ export declare function seatLadder(model: PlanModel): PlanModel["seatTypes"];
14
+ /**
15
+ * The seat a member holds when nobody has assigned them one: the cheapest non-shared type.
16
+ *
17
+ * An UNASSIGNED member is not on "no seat" — they draw the plan's entry-level pack, which is
18
+ * what the meter measures them against and what their badge says. Treating absent as zero
19
+ * made the ladder offer a Standard member the Standard seat they were already effectively on,
20
+ * which is how this was caught: the button read "Assegna Posto Standard".
21
+ */
22
+ export declare function defaultSeatOf(model: PlanModel): string | null;
23
+ /** What a seat costs per month — the ordering "a better seat" means. An absent assignment
24
+ * resolves to the default seat, not to nothing. */
25
+ export declare function seatRank(model: PlanModel, seatType: string | null): number;
26
+ /** The next seat type up, or null when they are already on the best one. */
27
+ export declare function nextSeatUp(model: PlanModel, seatType: string | null): string | null;
28
+ /**
29
+ * Is this the best seat the plan sells?
30
+ *
31
+ * `nextSeatUp(...) === null` already answered it, but only to a reader who knows that is what
32
+ * null means there. A screen deciding whether to offer an upgrade, and an agent asking the
33
+ * same question over the API, should not both have to know.
34
+ */
35
+ export declare function isTopSeat(model: PlanModel, seatType: string | null): boolean;
36
+ /**
37
+ * Does THIS plan sell that seat type?
38
+ *
39
+ * Against the org's own model, which is the check that was missing: the `assign_seat_type`
40
+ * tool validated against the union of seat keys across every plan in the catalogue, so a
41
+ * Premium key was "valid" for a workspace on a plan that does not sell it, and the write
42
+ * went through to a seat the meter then could not price.
43
+ *
44
+ * `shared` types count — an API caller genuinely holds one — so this is a wider question
45
+ * than `seatLadder`.
46
+ */
47
+ export declare function seatTypeExists(model: PlanModel, seatType: string): boolean;
48
+ /** What a plan costs a month at its default basket — the ordering "a better plan" means.
49
+ * A quote-only plan ranks above everything priced, because nothing self-serve exceeds it. */
50
+ export declare function planRank(model: PlanModel): number;
51
+ export interface PlanActions {
52
+ /** The next plan up, or null when already at the top. */
53
+ upgradeTo: string | null;
54
+ /** The next plan down, or null when already at the bottom. */
55
+ downgradeTo: string | null;
56
+ /** Whether there is a paid subscription to end. False on a free plan — there
57
+ * is nothing to cancel, so the action shouldn't be offered. */
58
+ canCancel: boolean;
59
+ /** The plan a cancellation lands on. */
60
+ cancelTo: string | null;
61
+ }
62
+ /**
63
+ * Which of upgrade / downgrade / cancel apply to an org on `currentPlan`.
64
+ *
65
+ * Pure, so a UI can hide what doesn't apply instead of offering an action that
66
+ * will be refused: no "upgrade" on the top plan, no "cancel" on a free one.
67
+ */
68
+ export declare function planActions(plans: PlanCatalog, currentPlan: string | null): PlanActions;
69
+ export interface PlanRequest {
70
+ id: string;
71
+ /** WorkOS user id of whoever asked. */
72
+ memberId: string;
73
+ /**
74
+ * WHAT they are asking to move: the workspace's plan, or their own seat.
75
+ *
76
+ * They are different asks with different prices and different approvers' reasoning — a
77
+ * seat upgrade costs one seat's difference and affects one person, a plan change moves
78
+ * everybody — but they queue in the same place, because to an owner they are one list of
79
+ * people waiting.
80
+ *
81
+ * Absent means "plan": the field was added after the queue existed, and a stored record
82
+ * without it is a plan request.
83
+ */
84
+ kind?: "plan" | "seat";
85
+ /** The target — a plan key, or a seat-type key when `kind` is "seat". */
86
+ plan: string;
87
+ status: "pending" | "done" | "denied";
88
+ createdAt: string;
89
+ /** Why they are asking, in their words. Optional, capped at 140 chars by the caller. */
90
+ note?: string;
91
+ }
92
+ /** Has the workspace already reached (or passed) what this request asked for? */
93
+ export declare function isSatisfied(request: PlanRequest, plans: PlanCatalog, currentPlan: string | null,
94
+ /** The asker's seat type now — needed only for a seat request. */
95
+ currentSeatType?: string | null): boolean;
96
+ /**
97
+ * WHICH ask to offer someone who is out of usage. One decision, in one place.
98
+ *
99
+ * The ladder climbs the cheapest, most targeted rung first, and each rung exists because the
100
+ * one below it cannot help:
101
+ *
102
+ * 1. a better SEAT — their pack is what their seat includes, so the way to have more of it
103
+ * is a bigger seat. A Standard member should be offered this, never a top-up: topping
104
+ * up buys them a few days and leaves them in the same place next week.
105
+ * 2. CREDITS, where money can actually lift the wall: a `covers: "included"` window paces
106
+ * only what the plan gives away, and a pack whose plan overflows to the wallet is the
107
+ * same statement. Paying works, permanently and without anybody's permission, so
108
+ * asking an owner for a free exception would be the worse of two available answers.
109
+ * 3. extra USAGE on the blocked window — the answer where money CANNOT help: a
110
+ * `covers: "all"` window is the product's own pace and no purchase touches it, so an
111
+ * exception somebody grants is the only door.
112
+ * 4. a PLAN change — for a plan with no per-member allowance at all. A pooled plan's
113
+ * windows belong to the workspace, so there is nothing personal to raise and
114
+ * `grant_top_up` refuses it outright.
115
+ *
116
+ * Rungs 2 and 3 were ONE rung, and it was wrong in whichever direction the deployment went.
117
+ * A plan whose card says pay-as-you-go sent a blocked member to ask an owner for something
118
+ * they could have bought in a click; a plan pacing the product offered credits that lift
119
+ * nothing, taking money for a wall that would still be there. Which of the two applies is
120
+ * not a preference — it is what `covers` says, so it is read rather than configured again.
121
+ *
122
+ * Returns null when nothing is blocked, which is when nothing should be offered — a control
123
+ * permanently on screen asks a question nobody at 40% can answer.
124
+ */
125
+ export declare function nextUsageAsk(model: PlanModel | null, input: {
126
+ /** From `topUpTargetOf` — what, if anything, is refusing them, and whether paying lifts it. */
127
+ blocked: {
128
+ kind: "rate" | "pack";
129
+ covers?: "all" | "included";
130
+ } | null;
131
+ seatType?: string | null;
132
+ plans: PlanCatalog;
133
+ currentPlan?: string | null;
134
+ }): {
135
+ ask: "seat";
136
+ to: string;
137
+ } | {
138
+ ask: "credits";
139
+ } | {
140
+ ask: "usage";
141
+ } | {
142
+ ask: "plan";
143
+ to: string;
144
+ } | null;
145
+ //# sourceMappingURL=ladder.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ladder.d.ts","sourceRoot":"","sources":["../src/ladder.ts"],"names":[],"mappings":"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"}
package/dist/ladder.js ADDED
@@ -0,0 +1,196 @@
1
+ import { defaultBasket, exhaustedPolicy, normalizePlans, planModel, } from "./plan-model.js";
2
+ // The RUNGS: what "a better seat" and "a better plan" mean, and which one to offer a
3
+ // member who is blocked.
4
+ //
5
+ // ── Why this is its own module ──────────────────────────────────────────────
6
+ //
7
+ // All of it is arithmetic over the catalogue — a plain object in, a key out, no adapter,
8
+ // no Stripe, no WorkOS. It used to live half in `subscription.ts` (which imports Stripe to
9
+ // move a subscription) and half in `plan-request.ts` (which needs an adapter to queue an
10
+ // ask), so a pricing page, a seat picker or a config file could not reach a single rung
11
+ // without loading the engine. Neither module could put it on the `/plans` leaf, and the
12
+ // leaf is asserted pure — so the ordering rule got re-implemented in consumer UIs instead,
13
+ // once per screen, which is how a seat picker and the meter can disagree about which seat
14
+ // somebody is on.
15
+ //
16
+ // Both former homes re-export from here, so nothing internal moved.
17
+ // ── Seats ───────────────────────────────────────────────────────────────────
18
+ /**
19
+ * The seat types a member can BE on, cheapest first.
20
+ *
21
+ * `shared` types are excluded: they are the pool an API caller or a guest draws from, not a
22
+ * rung a person climbs, and including them made "the next seat up" point at something no
23
+ * human can be assigned.
24
+ *
25
+ * This ordering — non-shared, by monthly price — is the definition of "better seat" used by
26
+ * every function below. It is exported because a UI that renders the ladder has to sort it
27
+ * the same way, and the only alternative to publishing the rule is each caller guessing it.
28
+ */
29
+ export function seatLadder(model) {
30
+ return [...model.seatTypes]
31
+ .filter((s) => !s.shared)
32
+ .sort((a, b) => a.price.monthly - b.price.monthly);
33
+ }
34
+ /**
35
+ * The seat a member holds when nobody has assigned them one: the cheapest non-shared type.
36
+ *
37
+ * An UNASSIGNED member is not on "no seat" — they draw the plan's entry-level pack, which is
38
+ * what the meter measures them against and what their badge says. Treating absent as zero
39
+ * made the ladder offer a Standard member the Standard seat they were already effectively on,
40
+ * which is how this was caught: the button read "Assegna Posto Standard".
41
+ */
42
+ export function defaultSeatOf(model) {
43
+ return seatLadder(model)[0]?.key ?? null;
44
+ }
45
+ /** What a seat costs per month — the ordering "a better seat" means. An absent assignment
46
+ * resolves to the default seat, not to nothing. */
47
+ export function seatRank(model, seatType) {
48
+ const key = seatType ?? defaultSeatOf(model);
49
+ if (!key)
50
+ return 0;
51
+ return model.seatTypes.find((s) => s.key === key)?.price.monthly ?? 0;
52
+ }
53
+ /** The next seat type up, or null when they are already on the best one. */
54
+ export function nextSeatUp(model, seatType) {
55
+ const above = seatLadder(model).filter((s) => s.price.monthly > seatRank(model, seatType));
56
+ return above[0]?.key ?? null;
57
+ }
58
+ /**
59
+ * Is this the best seat the plan sells?
60
+ *
61
+ * `nextSeatUp(...) === null` already answered it, but only to a reader who knows that is what
62
+ * null means there. A screen deciding whether to offer an upgrade, and an agent asking the
63
+ * same question over the API, should not both have to know.
64
+ */
65
+ export function isTopSeat(model, seatType) {
66
+ return seatLadder(model).length > 0 && nextSeatUp(model, seatType) === null;
67
+ }
68
+ /**
69
+ * Does THIS plan sell that seat type?
70
+ *
71
+ * Against the org's own model, which is the check that was missing: the `assign_seat_type`
72
+ * tool validated against the union of seat keys across every plan in the catalogue, so a
73
+ * Premium key was "valid" for a workspace on a plan that does not sell it, and the write
74
+ * went through to a seat the meter then could not price.
75
+ *
76
+ * `shared` types count — an API caller genuinely holds one — so this is a wider question
77
+ * than `seatLadder`.
78
+ */
79
+ export function seatTypeExists(model, seatType) {
80
+ return model.seatTypes.some((s) => s.key === seatType);
81
+ }
82
+ // ── Plans ───────────────────────────────────────────────────────────────────
83
+ /** What a plan costs a month at its default basket — the ordering "a better plan" means.
84
+ * A quote-only plan ranks above everything priced, because nothing self-serve exceeds it. */
85
+ export function planRank(model) {
86
+ if (model.sale === "quote")
87
+ return Number.MAX_SAFE_INTEGER;
88
+ const basket = defaultBasket(model);
89
+ if (model.sells.kind === "flat")
90
+ return model.sells.price.monthly;
91
+ return model.seatTypes.reduce((sum, s) => sum + (basket[s.key] ?? 0) * s.price.monthly, 0);
92
+ }
93
+ /**
94
+ * Which of upgrade / downgrade / cancel apply to an org on `currentPlan`.
95
+ *
96
+ * Pure, so a UI can hide what doesn't apply instead of offering an action that
97
+ * will be refused: no "upgrade" on the top plan, no "cancel" on a free one.
98
+ */
99
+ export function planActions(plans, currentPlan) {
100
+ const models = normalizePlans(plans)
101
+ .filter((m) => m.sale !== "legacy" && !m.display?.hidden)
102
+ .sort((a, b) => planRank(a) - planRank(b));
103
+ const free = models.find((m) => m.sale === "free") ?? null;
104
+ const current = currentPlan ? models.find((m) => m.key === currentPlan) : null;
105
+ // No recorded plan behaves as the free tier: nothing is being billed.
106
+ const rank = current ? planRank(current) : (free ? planRank(free) : 0);
107
+ const above = models.filter((m) => planRank(m) > rank);
108
+ const below = models.filter((m) => planRank(m) < rank);
109
+ const isPaid = current ? current.sells.kind !== "nothing" && current.sale !== "free" : false;
110
+ return {
111
+ upgradeTo: above[0]?.key ?? null,
112
+ downgradeTo: below[below.length - 1]?.key ?? null,
113
+ canCancel: isPaid,
114
+ cancelTo: free?.key ?? null,
115
+ };
116
+ }
117
+ /** Has the workspace already reached (or passed) what this request asked for? */
118
+ export function isSatisfied(request, plans, currentPlan,
119
+ /** The asker's seat type now — needed only for a seat request. */
120
+ currentSeatType) {
121
+ if (request.kind === "seat") {
122
+ const model = currentPlan ? planModel(plans, currentPlan) : null;
123
+ if (!model)
124
+ return false;
125
+ return seatRank(model, currentSeatType ?? null) >= seatRank(model, request.plan);
126
+ }
127
+ const want = planModel(plans, request.plan);
128
+ const have = currentPlan ? planModel(plans, currentPlan) : null;
129
+ if (!want || !have)
130
+ return false;
131
+ return planRank(have) >= planRank(want);
132
+ }
133
+ /**
134
+ * WHICH ask to offer someone who is out of usage. One decision, in one place.
135
+ *
136
+ * The ladder climbs the cheapest, most targeted rung first, and each rung exists because the
137
+ * one below it cannot help:
138
+ *
139
+ * 1. a better SEAT — their pack is what their seat includes, so the way to have more of it
140
+ * is a bigger seat. A Standard member should be offered this, never a top-up: topping
141
+ * up buys them a few days and leaves them in the same place next week.
142
+ * 2. CREDITS, where money can actually lift the wall: a `covers: "included"` window paces
143
+ * only what the plan gives away, and a pack whose plan overflows to the wallet is the
144
+ * same statement. Paying works, permanently and without anybody's permission, so
145
+ * asking an owner for a free exception would be the worse of two available answers.
146
+ * 3. extra USAGE on the blocked window — the answer where money CANNOT help: a
147
+ * `covers: "all"` window is the product's own pace and no purchase touches it, so an
148
+ * exception somebody grants is the only door.
149
+ * 4. a PLAN change — for a plan with no per-member allowance at all. A pooled plan's
150
+ * windows belong to the workspace, so there is nothing personal to raise and
151
+ * `grant_top_up` refuses it outright.
152
+ *
153
+ * Rungs 2 and 3 were ONE rung, and it was wrong in whichever direction the deployment went.
154
+ * A plan whose card says pay-as-you-go sent a blocked member to ask an owner for something
155
+ * they could have bought in a click; a plan pacing the product offered credits that lift
156
+ * nothing, taking money for a wall that would still be there. Which of the two applies is
157
+ * not a preference — it is what `covers` says, so it is read rather than configured again.
158
+ *
159
+ * Returns null when nothing is blocked, which is when nothing should be offered — a control
160
+ * permanently on screen asks a question nobody at 40% can answer.
161
+ */
162
+ export function nextUsageAsk(model, input) {
163
+ if (!input.blocked)
164
+ return null;
165
+ // Can the customer pay their own way past this? Two conditions, and both are necessary:
166
+ // the plan has to SELL credits, and the wall has to be one credits reach. A pack is
167
+ // reachable when the plan overflows to the wallet; a rate window only when it covers the
168
+ // included allowance alone.
169
+ const sellsCredits = Boolean(model?.replenish?.purchase || model?.replenish?.autoReload);
170
+ const payable = sellsCredits &&
171
+ (input.blocked.kind === "pack"
172
+ // Through `exhaustedPolicy`, not `cap.onExhausted`: an agent and a shared seat always
173
+ // overflow to the wallet whatever the cap declares, and a `cap: wallet` plan has no
174
+ // `onExhausted` field at all.
175
+ ? exhaustedPolicy(model, { seatType: input.seatType ?? undefined }) === "wallet"
176
+ : input.blocked.covers === "included");
177
+ if (model?.sells.kind === "seats") {
178
+ const better = nextSeatUp(model, input.seatType ?? null);
179
+ // The seat still comes first, even when credits would work: it raises the pack AND the
180
+ // pace every cycle, where credits are this week's answer bought again next week.
181
+ if (better)
182
+ return { ask: "seat", to: better };
183
+ // On the best seat: buy more if that is possible, otherwise ask for an exception.
184
+ return payable ? { ask: "credits" } : { ask: "usage" };
185
+ }
186
+ // A pooled plan has nothing personal to raise — but if it sells credits and the pool
187
+ // overflows to the wallet, paying is still a real answer and a better one than asking
188
+ // the workspace to change plan.
189
+ if (payable)
190
+ return { ask: "credits" };
191
+ // No seats, so nothing per-member to raise. The only route is the workspace buying more
192
+ // product — and if there is nothing above them, there is nothing to offer at all.
193
+ const up = planActions(input.plans, input.currentPlan ?? null).upgradeTo;
194
+ return up ? { ask: "plan", to: up } : null;
195
+ }
196
+ //# sourceMappingURL=ladder.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ladder.js","sourceRoot":"","sources":["../src/ladder.ts"],"names":[],"mappings":"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"}
@@ -1,26 +1,7 @@
1
- import { type PlanCatalog, type PlanModel } from "./plan-model.js";
1
+ import { type PlanRequest } from "./ladder.js";
2
+ import { type PlanCatalog } from "./plan-model.js";
2
3
  import type { BillingAdapter } from "./types.js";
3
- export interface PlanRequest {
4
- id: string;
5
- /** WorkOS user id of whoever asked. */
6
- memberId: string;
7
- /**
8
- * WHAT they are asking to move: the workspace's plan, or their own seat.
9
- *
10
- * They are different asks with different prices and different approvers' reasoning — a
11
- * seat upgrade costs one seat's difference and affects one person, a plan change moves
12
- * everybody — but they queue in the same place, because to an owner they are one list of
13
- * "people who need more". Absent means `plan`, so records written before seats existed
14
- * still read.
15
- */
16
- kind?: "plan" | "seat";
17
- /** The plan, or the seat type, they are asking to move to. */
18
- plan: string;
19
- status: "pending" | "done" | "denied";
20
- createdAt: string;
21
- /** Free text from the asker, trimmed hard — this shares one metadata value. */
22
- note?: string;
23
- }
4
+ export { defaultSeatOf, isSatisfied, isTopSeat, nextSeatUp, nextUsageAsk, seatLadder, seatRank, seatTypeExists, type PlanRequest, } from "./ladder.js";
24
5
  /** Every request, newest first. */
25
6
  export declare function listPlanRequests(adapter: BillingAdapter, orgId: string): Promise<PlanRequest[]>;
26
7
  /**
@@ -34,24 +15,6 @@ export declare function pendingPlanRequest(adapter: BillingAdapter, orgId: strin
34
15
  currentPlan?: string | null;
35
16
  currentSeatType?: string | null;
36
17
  }): Promise<PlanRequest | null>;
37
- /** Has the workspace already reached (or passed) what this request asked for? */
38
- export declare function isSatisfied(request: PlanRequest, plans: PlanCatalog, currentPlan: string | null,
39
- /** The asker's seat type now — needed only for a seat request. */
40
- currentSeatType?: string | null): boolean;
41
- /**
42
- * The seat a member holds when nobody has assigned them one: the cheapest non-shared type.
43
- *
44
- * An UNASSIGNED member is not on "no seat" — they draw the plan's entry-level pack, which is
45
- * what the meter measures them against and what their badge says. Treating absent as zero
46
- * made the ladder offer a Standard member the Standard seat they were already effectively on,
47
- * which is how this was caught: the button read "Assegna Posto Standard".
48
- */
49
- export declare function defaultSeatOf(model: PlanModel): string | null;
50
- /** What a seat costs per month — the ordering "a better seat" means. An absent assignment
51
- * resolves to the default seat, not to nothing. */
52
- export declare function seatRank(model: PlanModel, seatType: string | null): number;
53
- /** The next seat type up, or null when they are already on the best one. */
54
- export declare function nextSeatUp(model: PlanModel, seatType: string | null): string | null;
55
18
  /**
56
19
  * Ask to move up a SEAT — the answer when a better seat exists.
57
20
  *
@@ -109,53 +72,4 @@ export declare function requestPlanChange(adapter: BillingAdapter, orgId: string
109
72
  * reading of "approve".
110
73
  */
111
74
  export declare function resolvePlanRequest(adapter: BillingAdapter, orgId: string, requestId: string, status: "done" | "denied"): Promise<PlanRequest | null>;
112
- /**
113
- * WHICH ask to offer someone who is out of usage. One decision, in one place.
114
- *
115
- * The ladder climbs the cheapest, most targeted rung first, and each rung exists because the
116
- * one below it cannot help:
117
- *
118
- * 1. a better SEAT — their pack is what their seat includes, so the way to have more of it
119
- * is a bigger seat. A Standard member should be offered this, never a top-up: topping
120
- * up buys them a few days and leaves them in the same place next week.
121
- * 2. CREDITS, where money can actually lift the wall: a `covers: "included"` window paces
122
- * only what the plan gives away, and a pack whose plan overflows to the wallet is the
123
- * same statement. Paying works, permanently and without anybody's permission, so
124
- * asking an owner for a free exception would be the worse of two available answers.
125
- * 3. extra USAGE on the blocked window — the answer where money CANNOT help: a
126
- * `covers: "all"` window is the product's own pace and no purchase touches it, so an
127
- * exception somebody grants is the only door.
128
- * 4. a PLAN change — for a plan with no per-member allowance at all. A pooled plan's
129
- * windows belong to the workspace, so there is nothing personal to raise and
130
- * `grant_top_up` refuses it outright.
131
- *
132
- * Rungs 2 and 3 were ONE rung, and it was wrong in whichever direction the deployment went.
133
- * A plan whose card says pay-as-you-go sent a blocked member to ask an owner for something
134
- * they could have bought in a click; a plan pacing the product offered credits that lift
135
- * nothing, taking money for a wall that would still be there. Which of the two applies is
136
- * not a preference — it is what `covers` says, so it is read rather than configured again.
137
- *
138
- * Returns null when nothing is blocked, which is when nothing should be offered — a control
139
- * permanently on screen asks a question nobody at 40% can answer.
140
- */
141
- export declare function nextUsageAsk(model: PlanModel | null, input: {
142
- /** From `topUpTargetOf` — what, if anything, is refusing them, and whether paying lifts it. */
143
- blocked: {
144
- kind: "rate" | "pack";
145
- covers?: "all" | "included";
146
- } | null;
147
- seatType?: string | null;
148
- plans: PlanCatalog;
149
- currentPlan?: string | null;
150
- }): {
151
- ask: "seat";
152
- to: string;
153
- } | {
154
- ask: "credits";
155
- } | {
156
- ask: "usage";
157
- } | {
158
- ask: "plan";
159
- to: string;
160
- } | null;
161
75
  //# sourceMappingURL=plan-request.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"plan-request.d.ts","sourceRoot":"","sources":["../src/plan-request.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"plan-request.d.ts","sourceRoot":"","sources":["../src/plan-request.ts"],"names":[],"mappings":"AAAA,OAAO,EAML,KAAK,WAAW,EACjB,MAAM,aAAa,CAAC;AACrB,OAAO,EAA6B,KAAK,WAAW,EAAE,MAAM,iBAAiB,CAAC;AAC9E,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,YAAY,CAAC;AAKjD,OAAO,EACL,aAAa,EACb,WAAW,EACX,SAAS,EACT,UAAU,EACV,YAAY,EACZ,UAAU,EACV,QAAQ,EACR,cAAc,EACd,KAAK,WAAW,GACjB,MAAM,aAAa,CAAC;AAgFrB,mCAAmC;AACnC,wBAAsB,gBAAgB,CAAC,OAAO,EAAE,cAAc,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,WAAW,EAAE,CAAC,CAErG;AAED;;;;;GAKG;AACH,wBAAsB,kBAAkB,CACtC,OAAO,EAAE,cAAc,EACvB,KAAK,EAAE,MAAM,EACb,QAAQ,EAAE,MAAM,EAChB,IAAI,EAAE;IAAE,KAAK,EAAE,WAAW,CAAC;IAAC,WAAW,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAAC,eAAe,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;CAAE,GACzF,OAAO,CAAC,WAAW,GAAG,IAAI,CAAC,CAI7B;AAED;;;;;;;GAOG;AACH,wBAAsB,iBAAiB,CACrC,OAAO,EAAE,cAAc,EACvB,KAAK,EAAE,MAAM,EACb,KAAK,EAAE;IACL,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,WAAW,CAAC;IACnB,WAAW,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5B,eAAe,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAChC,oCAAoC;IACpC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,GAAG,CAAC,EAAE,MAAM,CAAC;CACd,GACA,OAAO,CAAC;IACT,EAAE,EAAE,OAAO,CAAC;IACZ,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,OAAO,CAAC,EAAE,WAAW,CAAC;IACtB,MAAM,CAAC,EAAE,YAAY,GAAG,cAAc,GAAG,iBAAiB,GAAG,eAAe,GAAG,YAAY,CAAC;CAC7F,CAAC,CA4BD;AAED;;;;;;GAMG;AACH,wBAAsB,iBAAiB,CACrC,OAAO,EAAE,cAAc,EACvB,KAAK,EAAE,MAAM,EACb,KAAK,EAAE;IACL,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,WAAW,CAAC;IACnB,WAAW,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5B,oCAAoC;IACpC,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,GAAG,CAAC,EAAE,MAAM,CAAC;CACd,GACA,OAAO,CAAC;IACT,EAAE,EAAE,OAAO,CAAC;IACZ,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,OAAO,CAAC,EAAE,WAAW,CAAC;IACtB,MAAM,CAAC,EAAE,YAAY,GAAG,cAAc,GAAG,iBAAiB,GAAG,eAAe,GAAG,YAAY,CAAC;CAC7F,CAAC,CAsCD;AAED;;;;;;;GAOG;AACH,wBAAsB,kBAAkB,CACtC,OAAO,EAAE,cAAc,EACvB,KAAK,EAAE,MAAM,EACb,SAAS,EAAE,MAAM,EACjB,MAAM,EAAE,MAAM,GAAG,QAAQ,GACxB,OAAO,CAAC,WAAW,GAAG,IAAI,CAAC,CAW7B"}
@@ -1,5 +1,9 @@
1
- import { planActions, planRank } from "./subscription.js";
2
- import { exhaustedPolicy, normalizePlans, planModel } from "./plan-model.js";
1
+ import { isSatisfied, nextSeatUp, planActions, seatRank, } from "./ladder.js";
2
+ import { normalizePlans, planModel } from "./plan-model.js";
3
+ // The rungs themselves are pure arithmetic and live in `ladder.ts`, which a pricing page or
4
+ // a seat picker can import without this module's adapter. Re-exported here so every existing
5
+ // import path keeps working.
6
+ export { defaultSeatOf, isSatisfied, isTopSeat, nextSeatUp, nextUsageAsk, seatLadder, seatRank, seatTypeExists, } from "./ladder.js";
3
7
  // "Can we move up a plan?" — the ask a member makes when extra allowance is not the answer.
4
8
  //
5
9
  // ── Why this is not a top-up ────────────────────────────────────────────────
@@ -91,52 +95,6 @@ export async function pendingPlanRequest(adapter, orgId, memberId, opts) {
91
95
  return null;
92
96
  return isSatisfied(open, opts.plans, opts.currentPlan ?? null, opts.currentSeatType ?? null) ? null : open;
93
97
  }
94
- /** Has the workspace already reached (or passed) what this request asked for? */
95
- export function isSatisfied(request, plans, currentPlan,
96
- /** The asker's seat type now — needed only for a seat request. */
97
- currentSeatType) {
98
- if (request.kind === "seat") {
99
- const model = currentPlan ? planModel(plans, currentPlan) : null;
100
- if (!model)
101
- return false;
102
- return seatRank(model, currentSeatType ?? null) >= seatRank(model, request.plan);
103
- }
104
- const want = planModel(plans, request.plan);
105
- const have = currentPlan ? planModel(plans, currentPlan) : null;
106
- if (!want || !have)
107
- return false;
108
- return planRank(have) >= planRank(want);
109
- }
110
- /**
111
- * The seat a member holds when nobody has assigned them one: the cheapest non-shared type.
112
- *
113
- * An UNASSIGNED member is not on "no seat" — they draw the plan's entry-level pack, which is
114
- * what the meter measures them against and what their badge says. Treating absent as zero
115
- * made the ladder offer a Standard member the Standard seat they were already effectively on,
116
- * which is how this was caught: the button read "Assegna Posto Standard".
117
- */
118
- export function defaultSeatOf(model) {
119
- const ladder = [...model.seatTypes]
120
- .filter((s) => !s.shared)
121
- .sort((a, b) => a.price.monthly - b.price.monthly);
122
- return ladder[0]?.key ?? null;
123
- }
124
- /** What a seat costs per month — the ordering "a better seat" means. An absent assignment
125
- * resolves to the default seat, not to nothing. */
126
- export function seatRank(model, seatType) {
127
- const key = seatType ?? defaultSeatOf(model);
128
- if (!key)
129
- return 0;
130
- return model.seatTypes.find((s) => s.key === key)?.price.monthly ?? 0;
131
- }
132
- /** The next seat type up, or null when they are already on the best one. */
133
- export function nextSeatUp(model, seatType) {
134
- const ladder = [...model.seatTypes]
135
- .filter((s) => !s.shared)
136
- .sort((a, b) => a.price.monthly - b.price.monthly);
137
- const above = ladder.filter((s) => s.price.monthly > seatRank(model, seatType));
138
- return above[0]?.key ?? null;
139
- }
140
98
  /**
141
99
  * Ask to move up a SEAT — the answer when a better seat exists.
142
100
  *
@@ -238,67 +196,4 @@ export async function resolvePlanRequest(adapter, orgId, requestId, status) {
238
196
  await write(adapter, orgId, list.map((r) => (r.id === requestId ? updated : r)));
239
197
  return updated;
240
198
  }
241
- /**
242
- * WHICH ask to offer someone who is out of usage. One decision, in one place.
243
- *
244
- * The ladder climbs the cheapest, most targeted rung first, and each rung exists because the
245
- * one below it cannot help:
246
- *
247
- * 1. a better SEAT — their pack is what their seat includes, so the way to have more of it
248
- * is a bigger seat. A Standard member should be offered this, never a top-up: topping
249
- * up buys them a few days and leaves them in the same place next week.
250
- * 2. CREDITS, where money can actually lift the wall: a `covers: "included"` window paces
251
- * only what the plan gives away, and a pack whose plan overflows to the wallet is the
252
- * same statement. Paying works, permanently and without anybody's permission, so
253
- * asking an owner for a free exception would be the worse of two available answers.
254
- * 3. extra USAGE on the blocked window — the answer where money CANNOT help: a
255
- * `covers: "all"` window is the product's own pace and no purchase touches it, so an
256
- * exception somebody grants is the only door.
257
- * 4. a PLAN change — for a plan with no per-member allowance at all. A pooled plan's
258
- * windows belong to the workspace, so there is nothing personal to raise and
259
- * `grant_top_up` refuses it outright.
260
- *
261
- * Rungs 2 and 3 were ONE rung, and it was wrong in whichever direction the deployment went.
262
- * A plan whose card says pay-as-you-go sent a blocked member to ask an owner for something
263
- * they could have bought in a click; a plan pacing the product offered credits that lift
264
- * nothing, taking money for a wall that would still be there. Which of the two applies is
265
- * not a preference — it is what `covers` says, so it is read rather than configured again.
266
- *
267
- * Returns null when nothing is blocked, which is when nothing should be offered — a control
268
- * permanently on screen asks a question nobody at 40% can answer.
269
- */
270
- export function nextUsageAsk(model, input) {
271
- if (!input.blocked)
272
- return null;
273
- // Can the customer pay their own way past this? Two conditions, and both are necessary:
274
- // the plan has to SELL credits, and the wall has to be one credits reach. A pack is
275
- // reachable when the plan overflows to the wallet; a rate window only when it covers the
276
- // included allowance alone.
277
- const sellsCredits = Boolean(model?.replenish?.purchase || model?.replenish?.autoReload);
278
- const payable = sellsCredits &&
279
- (input.blocked.kind === "pack"
280
- // Through `exhaustedPolicy`, not `cap.onExhausted`: an agent and a shared seat always
281
- // overflow to the wallet whatever the cap declares, and a `cap: wallet` plan has no
282
- // `onExhausted` field at all.
283
- ? exhaustedPolicy(model, { seatType: input.seatType ?? undefined }) === "wallet"
284
- : input.blocked.covers === "included");
285
- if (model?.sells.kind === "seats") {
286
- const better = nextSeatUp(model, input.seatType ?? null);
287
- // The seat still comes first, even when credits would work: it raises the pack AND the
288
- // pace every cycle, where credits are this week's answer bought again next week.
289
- if (better)
290
- return { ask: "seat", to: better };
291
- // On the best seat: buy more if that is possible, otherwise ask for an exception.
292
- return payable ? { ask: "credits" } : { ask: "usage" };
293
- }
294
- // A pooled plan has nothing personal to raise — but if it sells credits and the pool
295
- // overflows to the wallet, paying is still a real answer and a better one than asking
296
- // the workspace to change plan.
297
- if (payable)
298
- return { ask: "credits" };
299
- // No seats, so nothing per-member to raise. The only route is the workspace buying more
300
- // product — and if there is nothing above them, there is nothing to offer at all.
301
- const up = planActions(input.plans, input.currentPlan ?? null).upgradeTo;
302
- return up ? { ask: "plan", to: up } : null;
303
- }
304
199
  //# sourceMappingURL=plan-request.js.map