@kernhq/module-billing 0.2.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/LICENSE +662 -0
- package/dist/contract.d.ts +965 -0
- package/dist/contract.d.ts.map +1 -0
- package/dist/contract.js +265 -0
- package/dist/contract.js.map +1 -0
- package/dist/server/index.d.ts +11 -0
- package/dist/server/index.d.ts.map +1 -0
- package/dist/server/index.js +165 -0
- package/dist/server/index.js.map +1 -0
- package/dist/server/router.d.ts +697 -0
- package/dist/server/router.d.ts.map +1 -0
- package/dist/server/router.js +126 -0
- package/dist/server/router.js.map +1 -0
- package/dist/server/schema.d.ts +1030 -0
- package/dist/server/schema.d.ts.map +1 -0
- package/dist/server/schema.js +137 -0
- package/dist/server/schema.js.map +1 -0
- package/dist/server/services/entitlements.d.ts +3 -0
- package/dist/server/services/entitlements.d.ts.map +1 -0
- package/dist/server/services/entitlements.js +68 -0
- package/dist/server/services/entitlements.js.map +1 -0
- package/dist/server/services/plans.d.ts +20 -0
- package/dist/server/services/plans.d.ts.map +1 -0
- package/dist/server/services/plans.js +122 -0
- package/dist/server/services/plans.js.map +1 -0
- package/dist/server/services/stripe.d.ts +42 -0
- package/dist/server/services/stripe.d.ts.map +1 -0
- package/dist/server/services/stripe.js +267 -0
- package/dist/server/services/stripe.js.map +1 -0
- package/dist/server/services/subscriptions.d.ts +35 -0
- package/dist/server/services/subscriptions.d.ts.map +1 -0
- package/dist/server/services/subscriptions.js +193 -0
- package/dist/server/services/subscriptions.js.map +1 -0
- package/dist/server/services/usage.d.ts +40 -0
- package/dist/server/services/usage.d.ts.map +1 -0
- package/dist/server/services/usage.js +98 -0
- package/dist/server/services/usage.js.map +1 -0
- package/migrations/0000_init.sql +82 -0
- package/migrations/0001_rls.sql +18 -0
- package/migrations/meta/0000_snapshot.json +580 -0
- package/migrations/meta/_journal.json +20 -0
- package/package.json +73 -0
- package/src/client/api.ts +15 -0
- package/src/client/format.test.ts +64 -0
- package/src/client/index.ts +81 -0
- package/src/contract.ts +311 -0
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { eq, sql } from 'drizzle-orm';
|
|
2
|
+
import { workspaceUsage } from '../schema.js';
|
|
3
|
+
/** Ensure a usage row exists, so the counters below can be plain arithmetic. */
|
|
4
|
+
async function ensureRow(kernel, workspaceId) {
|
|
5
|
+
await kernel.database.db
|
|
6
|
+
.insert(workspaceUsage)
|
|
7
|
+
.values({ workspaceId, seats: 0, storageBytes: 0 })
|
|
8
|
+
.onConflictDoNothing();
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Move a counter by a delta.
|
|
12
|
+
*
|
|
13
|
+
* Arithmetic in SQL rather than read-modify-write in JavaScript: two members joining at once is the
|
|
14
|
+
* normal case, not a rare one, and the read-modify-write version loses one of them.
|
|
15
|
+
* `greatest(0, …)` because a counter that has drifted negative is a bug that must not also start
|
|
16
|
+
* refusing uploads — the nightly reconcile is what corrects it.
|
|
17
|
+
*/
|
|
18
|
+
export async function bump(kernel, workspaceId, delta) {
|
|
19
|
+
await ensureRow(kernel, workspaceId);
|
|
20
|
+
await kernel.database.db
|
|
21
|
+
.update(workspaceUsage)
|
|
22
|
+
.set({
|
|
23
|
+
...(delta.seats !== undefined
|
|
24
|
+
? { seats: sql `greatest(0, ${workspaceUsage.seats} + ${delta.seats})` }
|
|
25
|
+
: {}),
|
|
26
|
+
...(delta.storageBytes !== undefined
|
|
27
|
+
? { storageBytes: sql `greatest(0, ${workspaceUsage.storageBytes} + ${delta.storageBytes})` }
|
|
28
|
+
: {}),
|
|
29
|
+
updatedAt: new Date(),
|
|
30
|
+
})
|
|
31
|
+
.where(eq(workspaceUsage.workspaceId, workspaceId));
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Recount seats for one workspace and write the answer down.
|
|
35
|
+
*
|
|
36
|
+
* Seats are recounted rather than moved by a delta, because the events do not carry enough to do the
|
|
37
|
+
* arithmetic safely: `core.member.removed` does not say what role the person had, and
|
|
38
|
+
* `core.member.updated` does not say what role they had *before* — so a guest being promoted, or a
|
|
39
|
+
* member leaving, would each be counted wrongly. A count over one workspace's memberships is cheap;
|
|
40
|
+
* being wrong about what a customer is charged is not.
|
|
41
|
+
*/
|
|
42
|
+
export async function recountSeats(kernel, workspaceId) {
|
|
43
|
+
const { seats } = await kernel.call('core.workspaces.seats', { workspaceId });
|
|
44
|
+
await ensureRow(kernel, workspaceId);
|
|
45
|
+
await kernel.database.db
|
|
46
|
+
.update(workspaceUsage)
|
|
47
|
+
.set({ seats, updatedAt: new Date() })
|
|
48
|
+
.where(eq(workspaceUsage.workspaceId, workspaceId));
|
|
49
|
+
return seats;
|
|
50
|
+
}
|
|
51
|
+
export async function read(kernel, workspaceId) {
|
|
52
|
+
const [row] = await kernel.database.db
|
|
53
|
+
.select()
|
|
54
|
+
.from(workspaceUsage)
|
|
55
|
+
.where(eq(workspaceUsage.workspaceId, workspaceId))
|
|
56
|
+
.limit(1);
|
|
57
|
+
return {
|
|
58
|
+
seats: row?.seats ?? 0,
|
|
59
|
+
storageBytes: row?.storageBytes ?? 0,
|
|
60
|
+
updatedAt: row?.updatedAt ?? new Date(0),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Recount one workspace from core's own tables and write the answer down.
|
|
65
|
+
*
|
|
66
|
+
* Returns the drift it found. The caller logs it rather than swallowing it: a counter that keeps
|
|
67
|
+
* needing correction means an event is being missed somewhere, and silently fixing the number every
|
|
68
|
+
* night is how that goes unnoticed for a year.
|
|
69
|
+
*/
|
|
70
|
+
export async function reconcile(kernel, workspaceId) {
|
|
71
|
+
const counted = await kernel.call('core.workspaces.usage', { workspaceId });
|
|
72
|
+
const before = await read(kernel, workspaceId);
|
|
73
|
+
await kernel.database.db
|
|
74
|
+
.insert(workspaceUsage)
|
|
75
|
+
.values({
|
|
76
|
+
workspaceId,
|
|
77
|
+
seats: counted.seats,
|
|
78
|
+
storageBytes: counted.storageBytes,
|
|
79
|
+
reconciledAt: new Date(),
|
|
80
|
+
})
|
|
81
|
+
.onConflictDoUpdate({
|
|
82
|
+
target: workspaceUsage.workspaceId,
|
|
83
|
+
set: {
|
|
84
|
+
seats: counted.seats,
|
|
85
|
+
storageBytes: counted.storageBytes,
|
|
86
|
+
reconciledAt: new Date(),
|
|
87
|
+
updatedAt: new Date(),
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
return {
|
|
91
|
+
counted,
|
|
92
|
+
drift: {
|
|
93
|
+
seats: counted.seats - before.seats,
|
|
94
|
+
storageBytes: counted.storageBytes - before.storageBytes,
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
//# sourceMappingURL=usage.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"usage.js","sourceRoot":"","sources":["../../../src/server/services/usage.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
CREATE SCHEMA IF NOT EXISTS "mod_billing";
|
|
2
|
+
--> statement-breakpoint
|
|
3
|
+
CREATE TABLE "mod_billing"."invoices" (
|
|
4
|
+
"id" uuid PRIMARY KEY DEFAULT uuidv7() NOT NULL,
|
|
5
|
+
"workspace_id" uuid NOT NULL,
|
|
6
|
+
"stripe_invoice_id" text,
|
|
7
|
+
"number" text,
|
|
8
|
+
"status" text NOT NULL,
|
|
9
|
+
"total_minor" integer DEFAULT 0 NOT NULL,
|
|
10
|
+
"currency" text DEFAULT 'usd' NOT NULL,
|
|
11
|
+
"period_start" timestamp with time zone,
|
|
12
|
+
"period_end" timestamp with time zone,
|
|
13
|
+
"hosted_url" text,
|
|
14
|
+
"pdf_url" text,
|
|
15
|
+
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
|
16
|
+
);
|
|
17
|
+
--> statement-breakpoint
|
|
18
|
+
CREATE TABLE "mod_billing"."overrides" (
|
|
19
|
+
"workspace_id" uuid PRIMARY KEY NOT NULL,
|
|
20
|
+
"limits" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
|
21
|
+
"note" text DEFAULT '' NOT NULL,
|
|
22
|
+
"created_by" uuid,
|
|
23
|
+
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
|
24
|
+
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
|
25
|
+
);
|
|
26
|
+
--> statement-breakpoint
|
|
27
|
+
CREATE TABLE "mod_billing"."plans" (
|
|
28
|
+
"id" uuid PRIMARY KEY DEFAULT uuidv7() NOT NULL,
|
|
29
|
+
"slug" text NOT NULL,
|
|
30
|
+
"name" text NOT NULL,
|
|
31
|
+
"description" text DEFAULT '' NOT NULL,
|
|
32
|
+
"price_minor" integer DEFAULT 0 NOT NULL,
|
|
33
|
+
"currency" text DEFAULT 'usd' NOT NULL,
|
|
34
|
+
"interval" text DEFAULT 'month' NOT NULL,
|
|
35
|
+
"per_seat" boolean DEFAULT true NOT NULL,
|
|
36
|
+
"trial_days" integer DEFAULT 0 NOT NULL,
|
|
37
|
+
"limits" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
|
38
|
+
"stripe_price_id" text,
|
|
39
|
+
"highlights" jsonb DEFAULT '[]'::jsonb NOT NULL,
|
|
40
|
+
"published" boolean DEFAULT false NOT NULL,
|
|
41
|
+
"order" integer DEFAULT 100 NOT NULL,
|
|
42
|
+
"archived_at" timestamp with time zone,
|
|
43
|
+
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
|
44
|
+
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
|
45
|
+
);
|
|
46
|
+
--> statement-breakpoint
|
|
47
|
+
CREATE TABLE "mod_billing"."subscriptions" (
|
|
48
|
+
"workspace_id" uuid PRIMARY KEY NOT NULL,
|
|
49
|
+
"plan_id" uuid,
|
|
50
|
+
"status" text DEFAULT 'trialing' NOT NULL,
|
|
51
|
+
"seats_purchased" integer DEFAULT 0 NOT NULL,
|
|
52
|
+
"trial_ends_at" timestamp with time zone,
|
|
53
|
+
"current_period_end" timestamp with time zone,
|
|
54
|
+
"cancel_at_period_end" boolean DEFAULT false NOT NULL,
|
|
55
|
+
"stripe_customer_id" text,
|
|
56
|
+
"stripe_subscription_id" text,
|
|
57
|
+
"grace_ends_at" timestamp with time zone,
|
|
58
|
+
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
|
59
|
+
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
|
60
|
+
);
|
|
61
|
+
--> statement-breakpoint
|
|
62
|
+
CREATE TABLE "mod_billing"."webhook_events" (
|
|
63
|
+
"id" text PRIMARY KEY NOT NULL,
|
|
64
|
+
"type" text NOT NULL,
|
|
65
|
+
"received_at" timestamp with time zone DEFAULT now() NOT NULL
|
|
66
|
+
);
|
|
67
|
+
--> statement-breakpoint
|
|
68
|
+
CREATE TABLE "mod_billing"."workspace_usage" (
|
|
69
|
+
"workspace_id" uuid PRIMARY KEY NOT NULL,
|
|
70
|
+
"seats" integer DEFAULT 0 NOT NULL,
|
|
71
|
+
"storage_bytes" bigint DEFAULT 0 NOT NULL,
|
|
72
|
+
"reconciled_at" timestamp with time zone,
|
|
73
|
+
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
|
74
|
+
);
|
|
75
|
+
--> statement-breakpoint
|
|
76
|
+
CREATE INDEX "invoices_ws_idx" ON "mod_billing"."invoices" USING btree ("workspace_id","created_at");--> statement-breakpoint
|
|
77
|
+
CREATE UNIQUE INDEX "invoices_stripe_idx" ON "mod_billing"."invoices" USING btree ("stripe_invoice_id");--> statement-breakpoint
|
|
78
|
+
CREATE UNIQUE INDEX "plans_slug_idx" ON "mod_billing"."plans" USING btree ("slug");--> statement-breakpoint
|
|
79
|
+
CREATE INDEX "plans_published_idx" ON "mod_billing"."plans" USING btree ("published","order");--> statement-breakpoint
|
|
80
|
+
CREATE INDEX "subscriptions_status_idx" ON "mod_billing"."subscriptions" USING btree ("status");--> statement-breakpoint
|
|
81
|
+
CREATE UNIQUE INDEX "subscriptions_stripe_sub_idx" ON "mod_billing"."subscriptions" USING btree ("stripe_subscription_id");--> statement-breakpoint
|
|
82
|
+
CREATE INDEX "webhook_events_received_idx" ON "mod_billing"."webhook_events" USING btree ("received_at");
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
-- Row level security for this module's tenant tables.
|
|
2
|
+
--
|
|
3
|
+
-- Only `invoices` is one. An invoice is the customer's own record, read on their own billing screen
|
|
4
|
+
-- in their own workspace context, so it is isolated the way every other module's data is.
|
|
5
|
+
--
|
|
6
|
+
-- The rest of `mod_billing` — plans, subscriptions, workspace_usage, overrides, webhook_events — is
|
|
7
|
+
-- deliberately *not* row-level secured, and the reason is written where it will be read, at the top
|
|
8
|
+
-- of src/server/schema.ts: those rows are the instance operator's record *about* a workspace rather
|
|
9
|
+
-- than the workspace's own data, and the console that lists every workspace on the instance and the
|
|
10
|
+
-- jobs that enumerate them cannot work under a policy that returns nothing when `app.workspace_id`
|
|
11
|
+
-- is unset. Their isolation is enforced in the procedure layer instead.
|
|
12
|
+
alter table "mod_billing"."invoices" enable row level security;
|
|
13
|
+
--> statement-breakpoint
|
|
14
|
+
alter table "mod_billing"."invoices" force row level security;
|
|
15
|
+
--> statement-breakpoint
|
|
16
|
+
create policy "invoices_ws_isolation" on "mod_billing"."invoices"
|
|
17
|
+
using (workspace_id::text = current_setting('app.workspace_id', true))
|
|
18
|
+
with check (workspace_id::text = current_setting('app.workspace_id', true));
|