@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.
Files changed (46) hide show
  1. package/LICENSE +662 -0
  2. package/dist/contract.d.ts +965 -0
  3. package/dist/contract.d.ts.map +1 -0
  4. package/dist/contract.js +265 -0
  5. package/dist/contract.js.map +1 -0
  6. package/dist/server/index.d.ts +11 -0
  7. package/dist/server/index.d.ts.map +1 -0
  8. package/dist/server/index.js +165 -0
  9. package/dist/server/index.js.map +1 -0
  10. package/dist/server/router.d.ts +697 -0
  11. package/dist/server/router.d.ts.map +1 -0
  12. package/dist/server/router.js +126 -0
  13. package/dist/server/router.js.map +1 -0
  14. package/dist/server/schema.d.ts +1030 -0
  15. package/dist/server/schema.d.ts.map +1 -0
  16. package/dist/server/schema.js +137 -0
  17. package/dist/server/schema.js.map +1 -0
  18. package/dist/server/services/entitlements.d.ts +3 -0
  19. package/dist/server/services/entitlements.d.ts.map +1 -0
  20. package/dist/server/services/entitlements.js +68 -0
  21. package/dist/server/services/entitlements.js.map +1 -0
  22. package/dist/server/services/plans.d.ts +20 -0
  23. package/dist/server/services/plans.d.ts.map +1 -0
  24. package/dist/server/services/plans.js +122 -0
  25. package/dist/server/services/plans.js.map +1 -0
  26. package/dist/server/services/stripe.d.ts +42 -0
  27. package/dist/server/services/stripe.d.ts.map +1 -0
  28. package/dist/server/services/stripe.js +267 -0
  29. package/dist/server/services/stripe.js.map +1 -0
  30. package/dist/server/services/subscriptions.d.ts +35 -0
  31. package/dist/server/services/subscriptions.d.ts.map +1 -0
  32. package/dist/server/services/subscriptions.js +193 -0
  33. package/dist/server/services/subscriptions.js.map +1 -0
  34. package/dist/server/services/usage.d.ts +40 -0
  35. package/dist/server/services/usage.d.ts.map +1 -0
  36. package/dist/server/services/usage.js +98 -0
  37. package/dist/server/services/usage.js.map +1 -0
  38. package/migrations/0000_init.sql +82 -0
  39. package/migrations/0001_rls.sql +18 -0
  40. package/migrations/meta/0000_snapshot.json +580 -0
  41. package/migrations/meta/_journal.json +20 -0
  42. package/package.json +73 -0
  43. package/src/client/api.ts +15 -0
  44. package/src/client/format.test.ts +64 -0
  45. package/src/client/index.ts +81 -0
  46. 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));