@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.
- package/dist/bound-api.d.ts +16 -5
- package/dist/bound-api.d.ts.map +1 -1
- package/dist/bound-api.js +20 -3
- package/dist/bound-api.js.map +1 -1
- package/dist/create-billing.d.ts +14 -6
- package/dist/create-billing.d.ts.map +1 -1
- package/dist/entries/plans.d.ts +1 -0
- package/dist/entries/plans.d.ts.map +1 -1
- package/dist/entries/plans.js +9 -0
- package/dist/entries/plans.js.map +1 -1
- package/dist/index.d.ts +6 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +33 -5
- package/dist/index.js.map +1 -1
- package/dist/ladder.d.ts +145 -0
- package/dist/ladder.d.ts.map +1 -0
- package/dist/ladder.js +196 -0
- package/dist/ladder.js.map +1 -0
- package/dist/plan-request.d.ts +3 -89
- package/dist/plan-request.d.ts.map +1 -1
- package/dist/plan-request.js +6 -111
- package/dist/plan-request.js.map +1 -1
- package/dist/seats.d.ts +21 -0
- package/dist/seats.d.ts.map +1 -1
- package/dist/seats.js +47 -13
- package/dist/seats.js.map +1 -1
- package/dist/subscription.d.ts +2 -22
- package/dist/subscription.d.ts.map +1 -1
- package/dist/subscription.js +5 -34
- package/dist/subscription.js.map +1 -1
- package/dist/tools/management.d.ts.map +1 -1
- package/dist/tools/management.js +46 -8
- package/dist/tools/management.js.map +1 -1
- package/package.json +2 -2
package/dist/ladder.d.ts
ADDED
|
@@ -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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"}
|
package/dist/plan-request.d.ts
CHANGED
|
@@ -1,26 +1,7 @@
|
|
|
1
|
-
import { type
|
|
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
|
|
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":"
|
|
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"}
|
package/dist/plan-request.js
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
|
-
import { planActions,
|
|
2
|
-
import {
|
|
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
|