@copilotz/billing 0.0.0-stage → 1.0.0-rc.34
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/LICENSE +21 -0
- package/README.md +48 -2
- package/contract.d.ts +7 -0
- package/contract.js +7 -0
- package/credits/actions/check.d.ts +5 -0
- package/credits/actions/check.js +97 -0
- package/credits/actions/manage.d.ts +9 -0
- package/credits/actions/manage.js +266 -0
- package/credits/actions/state.d.ts +2 -0
- package/credits/actions/state.js +172 -0
- package/credits/collections/credit.d.ts +110 -0
- package/credits/collections/credit.js +31 -0
- package/credits/collections/payment.d.ts +139 -0
- package/credits/collections/payment.js +32 -0
- package/credits/collections/plan.d.ts +428 -0
- package/credits/collections/plan.js +97 -0
- package/credits/collections/room.d.ts +85 -0
- package/credits/collections/room.js +24 -0
- package/credits/contract.d.ts +19 -0
- package/credits/contract.js +12 -0
- package/credits/mod.d.ts +38 -0
- package/credits/mod.js +72 -0
- package/credits/shared/apply.d.ts +66 -0
- package/credits/shared/apply.js +106 -0
- package/credits/shared/settings.d.ts +70 -0
- package/credits/shared/settings.js +28 -0
- package/credits/shared/standing.d.ts +71 -0
- package/credits/shared/standing.js +235 -0
- package/credits/shared/walk.d.ts +50 -0
- package/credits/shared/walk.js +127 -0
- package/credits/views/web/add.d.ts +25 -0
- package/credits/views/web/add.js +92 -0
- package/credits/views/web/budget.d.ts +16 -0
- package/credits/views/web/budget.js +62 -0
- package/credits/views/web/composer.d.ts +13 -0
- package/credits/views/web/composer.js +65 -0
- package/credits/views/web/history.d.ts +12 -0
- package/credits/views/web/history.js +55 -0
- package/credits/views/web/numbers.d.ts +256 -0
- package/credits/views/web/numbers.js +291 -0
- package/credits/views/web/org.d.ts +31 -0
- package/credits/views/web/org.js +238 -0
- package/credits/views/web/plan.d.ts +28 -0
- package/credits/views/web/plan.js +84 -0
- package/credits/views/web/staff.d.ts +20 -0
- package/credits/views/web/staff.js +265 -0
- package/credits/views/web/style.d.ts +9 -0
- package/credits/views/web/style.js +190 -0
- package/credits/views/web/types.d.ts +17 -0
- package/credits/views/web/types.js +32 -0
- package/credits/web.d.ts +12 -0
- package/credits/web.js +33 -0
- package/mod.d.ts +26 -0
- package/mod.js +25 -0
- package/package.json +61 -4
- package/stripe/actions/checkout.d.ts +4 -0
- package/stripe/actions/checkout.js +188 -0
- package/stripe/actions/fact.d.ts +1 -0
- package/stripe/actions/fact.js +39 -0
- package/stripe/mod.d.ts +30 -0
- package/stripe/mod.js +52 -0
- package/stripe/routes/webhook.d.ts +16 -0
- package/stripe/routes/webhook.js +56 -0
- package/stripe/shared/api.d.ts +14 -0
- package/stripe/shared/api.js +42 -0
- package/stripe/shared/events.d.ts +42 -0
- package/stripe/shared/events.js +226 -0
- package/stripe/shared/settings.d.ts +39 -0
- package/stripe/shared/settings.js +10 -0
- package/web.d.ts +13 -0
- package/web.js +13 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 Copilotz Contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,49 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @copilotz/billing
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
What an org has to spend, and paying for it, for [Copilotz](https://github.com/copilotzhq/copilotz):
|
|
4
|
+
plans, credits and budgets on Copilotz's usage ledger, and Stripe as one way to pay.
|
|
5
|
+
|
|
6
|
+
```sh
|
|
7
|
+
npm install @copilotz/billing copilotz # Node 24+, Bun
|
|
8
|
+
deno add jsr:@copilotz/billing jsr:@copilotz/copilotz
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
It goes with Copilotz at the same version (they're released together): `@copilotz/billing@x.y.z`
|
|
12
|
+
with `copilotz@x.y.z`.
|
|
13
|
+
|
|
14
|
+
```ts
|
|
15
|
+
import { usage } from "copilotz/plugins/billing";
|
|
16
|
+
import { credits, stripe, stripeWebhook } from "@copilotz/billing";
|
|
17
|
+
|
|
18
|
+
const runtime = await createRuntime({
|
|
19
|
+
store,
|
|
20
|
+
plugins: [models, chat, ...access, usage, credits, stripe, stripeWebhook, app],
|
|
21
|
+
});
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
```ts
|
|
25
|
+
// In the browser: the org page's Billing, and the note above where you type.
|
|
26
|
+
import { creditsWeb } from "@copilotz/billing/web";
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
| Import | What it has |
|
|
30
|
+
| ---------------------------- | ------------------------------------------------------------------------------------ |
|
|
31
|
+
| `@copilotz/billing` | `credits`, `stripe`, `stripeWebhook` |
|
|
32
|
+
| `@copilotz/billing/web` | `creditsWeb` |
|
|
33
|
+
| `@copilotz/billing/contract` | what a way of paying tells `billing.apply` (`PaymentFact`…), a plan's terms, `unfit` |
|
|
34
|
+
|
|
35
|
+
- **`credits`**: the org pays. Its plan (`billingPlan`), credits (`credit`) and payments (`payment`)
|
|
36
|
+
are its own rows; a plan's terms are data, so a contract is a record, not code. Its balance is its
|
|
37
|
+
credits less what the ledger charged since its plan began, and the check (`billing.check`, named
|
|
38
|
+
by the `spending` resource that whatever spends reads) stops it at its limit. Staff change plans,
|
|
39
|
+
give credits, waive and record payments by hand; a provider's webhook calls `billing.apply`. The
|
|
40
|
+
ledger itself, `usage`, stays Copilotz's (`copilotz/plugins/billing`): metering every use is.
|
|
41
|
+
- **`stripe`**: Checkout and the customer portal are Stripe's pages, so no card reaches the app;
|
|
42
|
+
what was paid lands by a signed webhook (`stripeWebhook`, `POST /__stripe/<seller>`, in the
|
|
43
|
+
runtime of the app's one address: the directory's, with many orgs).
|
|
44
|
+
|
|
45
|
+
Through 1.0.0-rc.32 these were `credits`, `stripe` and `stripeWebhook` in
|
|
46
|
+
`copilotz/plugins/billing`, and `creditsWeb` in `copilotz/plugins/billing/web`. Their names
|
|
47
|
+
(collections, Actions, events, Views, routes, resources) are the same, so an app's data stays as it
|
|
48
|
+
is: only its imports change. See
|
|
49
|
+
[design/plugins.md](https://github.com/copilotzhq/copilotz/blob/main/design/plugins.md).
|
package/contract.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Billing's contract (`@copilotz/billing/contract`): credits', what a payment
|
|
3
|
+
* provider tells it (`PaymentFact`, `PeriodFact`, `PlanFact`), a plan's terms
|
|
4
|
+
* and how they're read, for plugins built on it. The rest is reached by name,
|
|
5
|
+
* through the runtime (`billing.apply`, `billing.state`…).
|
|
6
|
+
*/
|
|
7
|
+
export * from "./credits/contract.js";
|
package/contract.js
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Billing's contract (`@copilotz/billing/contract`): credits', what a payment
|
|
3
|
+
* provider tells it (`PaymentFact`, `PeriodFact`, `PlanFact`), a plan's terms
|
|
4
|
+
* and how they're read, for plugins built on it. The rest is reached by name,
|
|
5
|
+
* through the runtime (`billing.apply`, `billing.state`…).
|
|
6
|
+
*/
|
|
7
|
+
export * from "./credits/contract.js";
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export declare const checkAction: import("copilotz").ActionDef;
|
|
2
|
+
export declare const mayAction: import("copilotz").ActionDef;
|
|
3
|
+
export declare const spentAction: import("copilotz").ActionDef;
|
|
4
|
+
export declare const priceListAction: import("copilotz").ActionDef;
|
|
5
|
+
export declare const tierAction: import("copilotz").ActionDef;
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The check, for whatever's about to spend (the `spending` resource names
|
|
3
|
+
* these). All answer `{ ok }`, or `{ ok: false, why, message }`.
|
|
4
|
+
*
|
|
5
|
+
* - `billing.check` `{ purpose? }`: before a model call, a wake, a build. It
|
|
6
|
+
* reads `room`, which costs no database read. Memory can only say yes: when
|
|
7
|
+
* `room` is spent, or stale, what's due is written and it's worked out
|
|
8
|
+
* again from what's kept before anyone's stopped.
|
|
9
|
+
* - `billing.may` `{}`: the same, for a read (the sizes on offer), which can't
|
|
10
|
+
* write: without a fresh `room` it says yes, and the real thing decides.
|
|
11
|
+
* - `billing.spent` `{ price }`: the ledger, as it records a use: its price
|
|
12
|
+
* comes off `room`, in memory, before the record goes.
|
|
13
|
+
* - `billing.priceList` `{}`: the list the org's plan names, for rating.
|
|
14
|
+
* - `billing.tier` `{}`: the model tier the org's plan gives its agents (the
|
|
15
|
+
* `planTier` resource), or null: the plan it's on, or the one it would start
|
|
16
|
+
* on. A plan's terms are copied when an org moves to it; terms kept from
|
|
17
|
+
* before plans named a tier say what the app's plan of that name says now.
|
|
18
|
+
*/
|
|
19
|
+
import { defineAction } from "copilotz";
|
|
20
|
+
import { settingsOf } from "../shared/settings.js";
|
|
21
|
+
import { planOf, refresh, rooms, verdictOf } from "../shared/standing.js";
|
|
22
|
+
const OK = { ok: true };
|
|
23
|
+
export const checkAction = defineAction({
|
|
24
|
+
description: "Whether the org can spend now, and why not",
|
|
25
|
+
record: false,
|
|
26
|
+
input: {
|
|
27
|
+
type: "object",
|
|
28
|
+
properties: { purpose: { type: "string" } },
|
|
29
|
+
additionalProperties: false,
|
|
30
|
+
},
|
|
31
|
+
run: async (ctx, input) => {
|
|
32
|
+
const set = settingsOf(ctx);
|
|
33
|
+
if (!set.enforce || (input.purpose && set.free.includes(input.purpose)))
|
|
34
|
+
return OK;
|
|
35
|
+
const now = new Date().toISOString();
|
|
36
|
+
const room = await rooms(ctx).findOne({ _id: "room" });
|
|
37
|
+
if (room && now < room.until && verdictOf(room).ok)
|
|
38
|
+
return OK;
|
|
39
|
+
try {
|
|
40
|
+
return verdictOf(await refresh(ctx, now));
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
// Called from a read, which can't write: what's there answers, and nothing there says yes.
|
|
44
|
+
if (error.message === "Queries are read-only")
|
|
45
|
+
return room ? verdictOf(room) : OK;
|
|
46
|
+
throw error;
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
export const mayAction = defineAction({
|
|
51
|
+
description: "Whether the org can spend now, as far as a read can tell",
|
|
52
|
+
record: false,
|
|
53
|
+
input: { type: "object", additionalProperties: false },
|
|
54
|
+
run: async (ctx) => {
|
|
55
|
+
if (!settingsOf(ctx).enforce)
|
|
56
|
+
return OK;
|
|
57
|
+
const room = await rooms(ctx).findOne({ _id: "room" });
|
|
58
|
+
return room && new Date().toISOString() < room.until ? verdictOf(room) : OK;
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
export const spentAction = defineAction({
|
|
62
|
+
description: "Takes a use's price off what the org can still spend",
|
|
63
|
+
record: false,
|
|
64
|
+
input: {
|
|
65
|
+
type: "object",
|
|
66
|
+
properties: { price: { type: "number" } },
|
|
67
|
+
required: ["price"],
|
|
68
|
+
additionalProperties: false,
|
|
69
|
+
},
|
|
70
|
+
run: async (ctx, input) => {
|
|
71
|
+
if (!settingsOf(ctx).enforce)
|
|
72
|
+
return;
|
|
73
|
+
await rooms(ctx).updateOne({ _id: "room" }, {
|
|
74
|
+
$inc: { credits: -input.price, budget: -input.price },
|
|
75
|
+
}).catch((error) => {
|
|
76
|
+
// A use recorded from a read: its record goes, and the next working-out counts it.
|
|
77
|
+
if (error.message !== "Queries are read-only")
|
|
78
|
+
throw error;
|
|
79
|
+
});
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
export const priceListAction = defineAction({
|
|
83
|
+
description: "The price list the org's plan names",
|
|
84
|
+
record: false,
|
|
85
|
+
input: { type: "object", additionalProperties: false },
|
|
86
|
+
run: async (ctx) => (await planOf(ctx))?.terms.prices ?? null,
|
|
87
|
+
});
|
|
88
|
+
export const tierAction = defineAction({
|
|
89
|
+
description: "The model tier the org's plan gives its agents",
|
|
90
|
+
record: false,
|
|
91
|
+
input: { type: "object", additionalProperties: false },
|
|
92
|
+
run: async (ctx) => {
|
|
93
|
+
const set = settingsOf(ctx);
|
|
94
|
+
const plan = await planOf(ctx);
|
|
95
|
+
return plan?.terms.models ?? set.plans[plan?.name ?? set.start]?.models ?? null;
|
|
96
|
+
},
|
|
97
|
+
});
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export declare const setPlanAction: import("copilotz").ActionDef;
|
|
2
|
+
export declare const grantAction: import("copilotz").ActionDef;
|
|
3
|
+
export declare const waiveAction: import("copilotz").ActionDef;
|
|
4
|
+
export declare const payAction: import("copilotz").ActionDef;
|
|
5
|
+
export declare const budgetAction: import("copilotz").ActionDef;
|
|
6
|
+
export declare const redeemAction: import("copilotz").ActionDef;
|
|
7
|
+
export declare const applyAction: import("copilotz").ActionDef;
|
|
8
|
+
export declare const planAction: import("copilotz").ActionDef;
|
|
9
|
+
export declare const settleAction: import("copilotz").ActionDef;
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Changing what an org has, each in person (never a key or an agent).
|
|
3
|
+
*
|
|
4
|
+
* Platform staff: `billing.setPlan`, `billing.grant` (gift credits, or take
|
|
5
|
+
* some away), `billing.waive` (usage or seats, for a while or until lifted),
|
|
6
|
+
* `billing.pay` (money that came in by hand). Recorded, so the log keeps who
|
|
7
|
+
* did what. The org's owners and admins: `billing.budget` (its month's limit)
|
|
8
|
+
* and `billing.redeem` (a gift code).
|
|
9
|
+
*
|
|
10
|
+
* `billing.apply`: for trusted code, what a payment provider says, called with
|
|
11
|
+
* the provider's own id for it (`{ id: "stripe:evt_…" }`), so the same news
|
|
12
|
+
* twice answers from the log.
|
|
13
|
+
*/
|
|
14
|
+
import { defineAction, failure, forbidden, inPerson, invalid, personOf, } from "copilotz";
|
|
15
|
+
import { runsOrg } from "copilotz/plugins/access/contract";
|
|
16
|
+
import { changePlan, grantPeriod, pay, } from "../shared/apply.js";
|
|
17
|
+
import { settingsOf } from "../shared/settings.js";
|
|
18
|
+
import { endWaiver, ensurePlan, forget, planOf, refresh } from "../shared/standing.js";
|
|
19
|
+
const DAY = "^\\d{4}-\\d{2}-\\d{2}$";
|
|
20
|
+
const staffOnly = (ctx) => {
|
|
21
|
+
if (ctx.actor?.staff !== true)
|
|
22
|
+
throw forbidden("Only platform staff do this.");
|
|
23
|
+
};
|
|
24
|
+
async function runsIt(ctx) {
|
|
25
|
+
if (ctx.actor?.staff === true)
|
|
26
|
+
return;
|
|
27
|
+
const me = await ctx.call("org.me", {});
|
|
28
|
+
if (!runsOrg(me.role))
|
|
29
|
+
throw forbidden("Only the org's owners and admins do this.");
|
|
30
|
+
}
|
|
31
|
+
const by = (ctx) => personOf(ctx.actor) ?? ctx.actor?.id;
|
|
32
|
+
const plans = (ctx) => ctx.as().db.collection("billingPlan");
|
|
33
|
+
const termsSchema = { type: "object" };
|
|
34
|
+
export const setPlanAction = defineAction({
|
|
35
|
+
description: "Puts the org on a plan, or a contract of its own terms",
|
|
36
|
+
input: {
|
|
37
|
+
type: "object",
|
|
38
|
+
properties: {
|
|
39
|
+
name: { type: "string", minLength: 1 },
|
|
40
|
+
terms: termsSchema,
|
|
41
|
+
seats: { type: ["integer", "null"], minimum: 0 },
|
|
42
|
+
channel: { type: "string" },
|
|
43
|
+
note: { type: "string" },
|
|
44
|
+
},
|
|
45
|
+
required: ["name"],
|
|
46
|
+
additionalProperties: false,
|
|
47
|
+
},
|
|
48
|
+
allow: inPerson,
|
|
49
|
+
run: async (ctx, input) => {
|
|
50
|
+
staffOnly(ctx);
|
|
51
|
+
const { note: _note, ...fact } = input;
|
|
52
|
+
// By hand now, whatever collected before: what it said of its payments no longer applies.
|
|
53
|
+
await changePlan(ctx, {
|
|
54
|
+
channel: "manual",
|
|
55
|
+
status: null,
|
|
56
|
+
subscription: null,
|
|
57
|
+
...fact,
|
|
58
|
+
});
|
|
59
|
+
return {};
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
export const grantAction = defineAction({
|
|
63
|
+
description: "Gives the org credits, or takes some away",
|
|
64
|
+
input: {
|
|
65
|
+
type: "object",
|
|
66
|
+
properties: {
|
|
67
|
+
amount: { type: "number" },
|
|
68
|
+
reason: { type: "string", minLength: 1 },
|
|
69
|
+
expires: { type: "string", pattern: DAY },
|
|
70
|
+
},
|
|
71
|
+
required: ["amount", "reason"],
|
|
72
|
+
additionalProperties: false,
|
|
73
|
+
},
|
|
74
|
+
allow: inPerson,
|
|
75
|
+
run: async (ctx, input) => {
|
|
76
|
+
staffOnly(ctx);
|
|
77
|
+
if (!input.amount)
|
|
78
|
+
throw invalid("Say how much.");
|
|
79
|
+
const now = new Date().toISOString();
|
|
80
|
+
await ensurePlan(ctx, now);
|
|
81
|
+
const id = await ctx.as().db.collection("credit").insertOne({
|
|
82
|
+
_id: `grant:${crypto.randomUUID()}`,
|
|
83
|
+
kind: input.amount > 0 ? "gift" : "adjustment",
|
|
84
|
+
amount: input.amount,
|
|
85
|
+
at: now,
|
|
86
|
+
reason: input.reason,
|
|
87
|
+
...(input.expires ? { expires: input.expires } : {}),
|
|
88
|
+
...(by(ctx) ? { by: by(ctx) } : {}),
|
|
89
|
+
});
|
|
90
|
+
await forget(ctx);
|
|
91
|
+
return { credit: id.insertedId };
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
export const waiveAction = defineAction({
|
|
95
|
+
description: "Waives the org's usage or seats, or lifts that",
|
|
96
|
+
input: {
|
|
97
|
+
type: "object",
|
|
98
|
+
properties: {
|
|
99
|
+
what: { type: "string", enum: ["usage", "seats"] },
|
|
100
|
+
/** To stop waiving it. */
|
|
101
|
+
lift: { type: "boolean" },
|
|
102
|
+
reason: { type: "string", minLength: 1 },
|
|
103
|
+
until: { type: "string" },
|
|
104
|
+
people: { type: "array", items: { type: "string" } },
|
|
105
|
+
},
|
|
106
|
+
required: ["what"],
|
|
107
|
+
additionalProperties: false,
|
|
108
|
+
},
|
|
109
|
+
allow: inPerson,
|
|
110
|
+
run: async (ctx, input) => {
|
|
111
|
+
staffOnly(ctx);
|
|
112
|
+
const now = new Date().toISOString();
|
|
113
|
+
const plan = await ensurePlan(ctx, now);
|
|
114
|
+
if (input.lift) {
|
|
115
|
+
if (input.what === "usage")
|
|
116
|
+
await endWaiver(ctx, plan, now);
|
|
117
|
+
else
|
|
118
|
+
await plans(ctx).updateOne({ _id: "plan" }, { $unset: { "waived.seats": "" } });
|
|
119
|
+
}
|
|
120
|
+
else {
|
|
121
|
+
if (!input.reason)
|
|
122
|
+
throw invalid("Say why it's waived.");
|
|
123
|
+
if (input.until && input.until <= now)
|
|
124
|
+
throw invalid("That's already past.");
|
|
125
|
+
// Waiving usage again keeps when it began, so all of it is given back when it ends.
|
|
126
|
+
const from = plan.waived?.[input.what]?.from ?? now;
|
|
127
|
+
await plans(ctx).updateOne({ _id: "plan" }, {
|
|
128
|
+
$set: {
|
|
129
|
+
waived: {
|
|
130
|
+
...plan.waived,
|
|
131
|
+
[input.what]: {
|
|
132
|
+
from,
|
|
133
|
+
reason: input.reason,
|
|
134
|
+
...(input.until ? { until: input.until } : {}),
|
|
135
|
+
...(by(ctx) ? { by: by(ctx) } : {}),
|
|
136
|
+
...(input.what === "seats" && input.people?.length ? { people: input.people } : {}),
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
await forget(ctx);
|
|
143
|
+
return {};
|
|
144
|
+
},
|
|
145
|
+
});
|
|
146
|
+
const paymentProperties = {
|
|
147
|
+
ref: { type: "string", minLength: 1 },
|
|
148
|
+
amount: { type: "number" },
|
|
149
|
+
currency: { type: "string", minLength: 3, maxLength: 3 },
|
|
150
|
+
rate: { type: "number", exclusiveMinimum: 0 },
|
|
151
|
+
fee: { type: "number", minimum: 0 },
|
|
152
|
+
purpose: { type: "string", enum: ["seats", "credits", "contract", "refund"] },
|
|
153
|
+
credits: { type: "number" },
|
|
154
|
+
note: { type: "string" },
|
|
155
|
+
};
|
|
156
|
+
export const payAction = defineAction({
|
|
157
|
+
description: "Keeps money that came in by hand, and the credits it bought",
|
|
158
|
+
input: {
|
|
159
|
+
type: "object",
|
|
160
|
+
properties: paymentProperties,
|
|
161
|
+
required: ["ref", "amount", "currency", "purpose"],
|
|
162
|
+
additionalProperties: false,
|
|
163
|
+
},
|
|
164
|
+
allow: inPerson,
|
|
165
|
+
run: async (ctx, input) => {
|
|
166
|
+
staffOnly(ctx);
|
|
167
|
+
await ensurePlan(ctx, new Date().toISOString());
|
|
168
|
+
await pay(ctx, {
|
|
169
|
+
channel: "manual",
|
|
170
|
+
...input,
|
|
171
|
+
currency: input.currency.toLowerCase(),
|
|
172
|
+
...(by(ctx) ? { by: by(ctx) } : {}),
|
|
173
|
+
});
|
|
174
|
+
return {};
|
|
175
|
+
},
|
|
176
|
+
});
|
|
177
|
+
export const budgetAction = defineAction({
|
|
178
|
+
description: "Sets the org's limit for a month, or removes it",
|
|
179
|
+
input: {
|
|
180
|
+
type: "object",
|
|
181
|
+
properties: { usd: { type: ["number", "null"], minimum: 0 } },
|
|
182
|
+
required: ["usd"],
|
|
183
|
+
additionalProperties: false,
|
|
184
|
+
},
|
|
185
|
+
allow: inPerson,
|
|
186
|
+
run: async (ctx, input) => {
|
|
187
|
+
await runsIt(ctx);
|
|
188
|
+
await ensurePlan(ctx, new Date().toISOString());
|
|
189
|
+
await plans(ctx).updateOne({ _id: "plan" }, { $set: { budget: input.usd } });
|
|
190
|
+
await forget(ctx);
|
|
191
|
+
return { budget: input.usd };
|
|
192
|
+
},
|
|
193
|
+
});
|
|
194
|
+
export const redeemAction = defineAction({
|
|
195
|
+
description: "Adds a gift code's credits to the org",
|
|
196
|
+
// A code is in no event.
|
|
197
|
+
record: false,
|
|
198
|
+
input: {
|
|
199
|
+
type: "object",
|
|
200
|
+
properties: { code: { type: "string", minLength: 1, maxLength: 80 } },
|
|
201
|
+
required: ["code"],
|
|
202
|
+
additionalProperties: false,
|
|
203
|
+
},
|
|
204
|
+
allow: inPerson,
|
|
205
|
+
run: async (ctx, input) => {
|
|
206
|
+
await runsIt(ctx);
|
|
207
|
+
const port = ctx.resources.directory;
|
|
208
|
+
if (!port?.claimCode)
|
|
209
|
+
throw failure("no_codes", "This app doesn't take gift codes.");
|
|
210
|
+
const person = by(ctx) ?? "";
|
|
211
|
+
const claimed = await port.claimCode(input.code, person);
|
|
212
|
+
const now = new Date().toISOString();
|
|
213
|
+
await ensurePlan(ctx, now);
|
|
214
|
+
// Claiming again for this org is the same code: the same `_id`, so it lands once.
|
|
215
|
+
await ctx.as().db.collection("credit").insertOne({
|
|
216
|
+
_id: `gift:${claimed.id}`,
|
|
217
|
+
kind: "gift",
|
|
218
|
+
amount: claimed.amount,
|
|
219
|
+
at: now,
|
|
220
|
+
...(claimed.expires ? { expires: claimed.expires } : {}),
|
|
221
|
+
...(claimed.note ? { note: claimed.note } : {}),
|
|
222
|
+
...(person ? { by: person } : {}),
|
|
223
|
+
});
|
|
224
|
+
await forget(ctx);
|
|
225
|
+
return { amount: claimed.amount, ...(claimed.expires ? { expires: claimed.expires } : {}) };
|
|
226
|
+
},
|
|
227
|
+
});
|
|
228
|
+
export const applyAction = defineAction({
|
|
229
|
+
description: "What a payment provider says: a payment, or a plan changed",
|
|
230
|
+
input: {
|
|
231
|
+
type: "object",
|
|
232
|
+
properties: {
|
|
233
|
+
payment: { type: "object" },
|
|
234
|
+
plan: { type: "object" },
|
|
235
|
+
period: { type: "object" },
|
|
236
|
+
},
|
|
237
|
+
additionalProperties: false,
|
|
238
|
+
},
|
|
239
|
+
run: async (ctx, input) => {
|
|
240
|
+
await ensurePlan(ctx, new Date().toISOString());
|
|
241
|
+
if (input.plan)
|
|
242
|
+
await changePlan(ctx, input.plan);
|
|
243
|
+
if (input.payment)
|
|
244
|
+
await pay(ctx, input.payment);
|
|
245
|
+
if (input.period)
|
|
246
|
+
await grantPeriod(ctx, input.period);
|
|
247
|
+
return {};
|
|
248
|
+
},
|
|
249
|
+
});
|
|
250
|
+
export const planAction = defineAction({
|
|
251
|
+
description: "The org's plan, started if it hasn't one",
|
|
252
|
+
record: false,
|
|
253
|
+
input: { type: "object", additionalProperties: false },
|
|
254
|
+
run: (ctx) => ensurePlan(ctx, new Date().toISOString()),
|
|
255
|
+
});
|
|
256
|
+
export const settleAction = defineAction({
|
|
257
|
+
description: "Writes what's come due: the trial, this period's credits, a waiver that ended",
|
|
258
|
+
input: { type: "object", additionalProperties: false },
|
|
259
|
+
run: async (ctx) => {
|
|
260
|
+
// Nothing starts for an org until something needs it, nor with limits off.
|
|
261
|
+
if (!settingsOf(ctx).enforce || !await planOf(ctx))
|
|
262
|
+
return {};
|
|
263
|
+
await refresh(ctx, ctx.scheduled?.at ?? new Date().toISOString());
|
|
264
|
+
return {};
|
|
265
|
+
},
|
|
266
|
+
});
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What people see of where the org stands.
|
|
3
|
+
*
|
|
4
|
+
* - `billing.state` `{}`: for the Billing section. Its people see whether work
|
|
5
|
+
* can go on and why not; its owners and admins, the plan, balance, month,
|
|
6
|
+
* credits and payments, and the plans it can move to (`pay.plans`: those for
|
|
7
|
+
* its kind of org that its way of paying sells it now, its own kind's first;
|
|
8
|
+
* `pay.elsewhere`, those only another kind of org can have); platform staff,
|
|
9
|
+
* also the plans there are to set.
|
|
10
|
+
* With limits off (`enforce`), only staff see anything, and nothing starts.
|
|
11
|
+
* - `billing.notice` `{}`: what to say above the composer, if anything: that
|
|
12
|
+
* the org can't spend (to everyone), or is running low (to those who can act
|
|
13
|
+
* on it).
|
|
14
|
+
*
|
|
15
|
+
* Both only read and settle what's due, so neither is recorded.
|
|
16
|
+
*/
|
|
17
|
+
import { defineAction, people } from "copilotz";
|
|
18
|
+
import { runsOrg } from "copilotz/plugins/access/contract";
|
|
19
|
+
import { billableSeats } from "../contract.js";
|
|
20
|
+
import { orgKind, settingsOf, unfit, } from "../shared/settings.js";
|
|
21
|
+
import { NO_LIMIT, refresh, rooms, seatsOf, standingOf, verdictOf } from "../shared/standing.js";
|
|
22
|
+
/** A waiver as who runs the org sees it: what, and until when; why and who are staff's. */
|
|
23
|
+
const withoutWhy = (waived) => Object.fromEntries(Object.entries(waived).map(([what, w]) => {
|
|
24
|
+
const { reason: _reason, by: _by, ...shown } = w;
|
|
25
|
+
return [what, shown];
|
|
26
|
+
}));
|
|
27
|
+
/** A plan as it's offered: what it is, costs and gives, and who it's for. */
|
|
28
|
+
const offer = ([name, terms]) => ({
|
|
29
|
+
name,
|
|
30
|
+
title: terms.title,
|
|
31
|
+
seat: terms.seat,
|
|
32
|
+
credits: terms.credits,
|
|
33
|
+
...(terms.for ? { for: terms.for } : {}),
|
|
34
|
+
});
|
|
35
|
+
/**
|
|
36
|
+
* The plans an org can move to by itself: those a seat's price is known for,
|
|
37
|
+
* that its way of paying can sell it now, for its kind of org (its own kind's
|
|
38
|
+
* first); and apart, those only another kind of org can have.
|
|
39
|
+
*/
|
|
40
|
+
async function offersOf(ctx, set) {
|
|
41
|
+
const kind = orgKind(ctx);
|
|
42
|
+
const sold = set.pay?.sellable
|
|
43
|
+
? new Set(await ctx.call(set.pay.sellable, {}))
|
|
44
|
+
: undefined;
|
|
45
|
+
const priced = Object.entries(set.plans)
|
|
46
|
+
.filter(([name, terms]) => terms.seat && (!sold || sold.has(name)));
|
|
47
|
+
const fits = priced.filter(([, terms]) => !unfit(terms, kind))
|
|
48
|
+
.sort(([, a], [, b]) => Number(b.for === kind) - Number(a.for === kind));
|
|
49
|
+
const elsewhere = priced.filter(([, terms]) => unfit(terms, kind));
|
|
50
|
+
return {
|
|
51
|
+
plans: fits.map(offer),
|
|
52
|
+
...(elsewhere.length ? { elsewhere: elsewhere.map(offer) } : {}),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
async function sight(ctx) {
|
|
56
|
+
const me = await ctx.call("org.me", {})
|
|
57
|
+
.catch(() => undefined);
|
|
58
|
+
const staff = me?.staff === true || ctx.actor?.staff === true;
|
|
59
|
+
return { staff, runs: staff || runsOrg(me?.role) };
|
|
60
|
+
}
|
|
61
|
+
export const stateAction = defineAction({
|
|
62
|
+
description: "Where the org stands: whether it can spend; its plan and credits for who runs it",
|
|
63
|
+
record: false,
|
|
64
|
+
input: { type: "object", additionalProperties: false },
|
|
65
|
+
allow: people,
|
|
66
|
+
run: async (ctx) => {
|
|
67
|
+
const { staff, runs } = await sight(ctx);
|
|
68
|
+
const set = settingsOf(ctx);
|
|
69
|
+
const now = new Date().toISOString();
|
|
70
|
+
if (!set.enforce && !staff)
|
|
71
|
+
return { on: false, runs, staff };
|
|
72
|
+
const room = set.enforce ? await refresh(ctx, now) : undefined;
|
|
73
|
+
const verdict = room ? verdictOf(room) : { ok: true };
|
|
74
|
+
if (!runs)
|
|
75
|
+
return { on: true, runs, staff, ...verdict };
|
|
76
|
+
const s = await standingOf(ctx, now);
|
|
77
|
+
const seats = s.plan ? await seatsOf(ctx, s.plan) : undefined;
|
|
78
|
+
const payments = await ctx.as().db.collection("payment").find({}, {
|
|
79
|
+
sort: { at: -1 },
|
|
80
|
+
limit: 50,
|
|
81
|
+
});
|
|
82
|
+
const lowOn = !room
|
|
83
|
+
? undefined
|
|
84
|
+
: room.lowCredits !== undefined && room.credits < room.lowCredits
|
|
85
|
+
? "credits"
|
|
86
|
+
: room.lowBudget !== undefined && room.budget < room.lowBudget
|
|
87
|
+
? "budget"
|
|
88
|
+
: undefined;
|
|
89
|
+
return {
|
|
90
|
+
on: set.enforce,
|
|
91
|
+
runs,
|
|
92
|
+
staff,
|
|
93
|
+
...verdict,
|
|
94
|
+
low: !!lowOn,
|
|
95
|
+
...(lowOn ? { lowOn } : {}),
|
|
96
|
+
plan: s.plan && {
|
|
97
|
+
name: s.plan.name,
|
|
98
|
+
terms: s.plan.terms,
|
|
99
|
+
since: s.plan.since,
|
|
100
|
+
channel: s.plan.channel,
|
|
101
|
+
...(s.plan.status ? { status: s.plan.status } : {}),
|
|
102
|
+
...(s.plan.customer ? { customer: s.plan.customer } : {}),
|
|
103
|
+
...(s.plan.subscription ? { subscription: s.plan.subscription } : {}),
|
|
104
|
+
...(typeof s.plan.seats === "number" ? { fixedSeats: s.plan.seats } : {}),
|
|
105
|
+
...(s.plan.waived ? { waived: staff ? s.plan.waived : withoutWhy(s.plan.waived) } : {}),
|
|
106
|
+
},
|
|
107
|
+
...(seats !== undefined ? { seats, billedSeats: billableSeats(s.plan, seats, now) } : {}),
|
|
108
|
+
...(s.period ? { period: s.period } : {}),
|
|
109
|
+
balance: s.walked.balance,
|
|
110
|
+
buckets: s.walked.buckets,
|
|
111
|
+
debt: s.walked.debt,
|
|
112
|
+
waived: s.waivedUsage,
|
|
113
|
+
month: s.month,
|
|
114
|
+
// Why it was given, and by whom, for staff; those who run the org, what it is.
|
|
115
|
+
history: s.history.slice(0, 100).map(({ by: who, reason, ...credit }) => staff
|
|
116
|
+
? {
|
|
117
|
+
...credit,
|
|
118
|
+
...(credit.note ?? reason ? { note: credit.note ?? reason } : {}),
|
|
119
|
+
...(who ? { by: who } : {}),
|
|
120
|
+
}
|
|
121
|
+
: credit),
|
|
122
|
+
// What it was and came to, for who runs the org; what a channel kept, and who, for staff.
|
|
123
|
+
payments: staff ? payments : payments.map((p) => ({
|
|
124
|
+
_id: p._id,
|
|
125
|
+
channel: p.channel,
|
|
126
|
+
ref: p.ref,
|
|
127
|
+
amount: p.amount,
|
|
128
|
+
currency: p.currency,
|
|
129
|
+
purpose: p.purpose,
|
|
130
|
+
...(p.credits !== undefined ? { credits: p.credits } : {}),
|
|
131
|
+
...(p.note ? { note: p.note } : {}),
|
|
132
|
+
at: p.at,
|
|
133
|
+
})),
|
|
134
|
+
pay: set.pay
|
|
135
|
+
? {
|
|
136
|
+
topUps: set.topUps,
|
|
137
|
+
min: set.topUpMin,
|
|
138
|
+
max: set.topUpMax,
|
|
139
|
+
...await offersOf(ctx, set),
|
|
140
|
+
manage: !!s.plan?.customer,
|
|
141
|
+
}
|
|
142
|
+
: null,
|
|
143
|
+
...(staff ? { plans: set.plans } : {}),
|
|
144
|
+
};
|
|
145
|
+
},
|
|
146
|
+
});
|
|
147
|
+
export const noticeAction = defineAction({
|
|
148
|
+
description: "What to say above the composer about what the org can spend, if anything",
|
|
149
|
+
record: false,
|
|
150
|
+
input: { type: "object", additionalProperties: false },
|
|
151
|
+
allow: people,
|
|
152
|
+
run: async (ctx) => {
|
|
153
|
+
if (!settingsOf(ctx).enforce)
|
|
154
|
+
return null;
|
|
155
|
+
const now = new Date().toISOString();
|
|
156
|
+
const kept = await rooms(ctx).findOne({ _id: "room" });
|
|
157
|
+
const room = kept && now < kept.until ? kept : await refresh(ctx, now);
|
|
158
|
+
const { runs } = await sight(ctx);
|
|
159
|
+
const verdict = verdictOf(room);
|
|
160
|
+
if (!verdict.ok)
|
|
161
|
+
return { tone: "stop", why: verdict.why, text: verdict.message, runs };
|
|
162
|
+
if (!runs)
|
|
163
|
+
return null;
|
|
164
|
+
if (room.lowCredits !== undefined && room.credits < room.lowCredits) {
|
|
165
|
+
return { tone: "low", why: "credits", left: Math.max(room.credits, 0), runs };
|
|
166
|
+
}
|
|
167
|
+
if (room.lowBudget !== undefined && room.budget < room.lowBudget && room.budget < NO_LIMIT) {
|
|
168
|
+
return { tone: "low", why: "budget", left: Math.max(room.budget, 0), runs };
|
|
169
|
+
}
|
|
170
|
+
return null;
|
|
171
|
+
},
|
|
172
|
+
});
|