nomkit 0.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/LICENSE.txt +21 -0
- package/dist/_virtual/_rolldown/runtime.js +27 -0
- package/dist/adapters/index.d.ts +15 -0
- package/dist/adapters/index.js +6 -0
- package/dist/cli/commands/push.d.ts +6 -0
- package/dist/cli/commands/push.js +143 -0
- package/dist/cli/index.d.ts +4 -0
- package/dist/cli/index.js +18 -0
- package/dist/cli/lib/collection_sync.d.ts +107 -0
- package/dist/cli/lib/collection_sync.js +158 -0
- package/dist/cli/lib/config_loader.d.ts +15 -0
- package/dist/cli/lib/config_loader.js +43 -0
- package/dist/cli/lib/hash.d.ts +22 -0
- package/dist/cli/lib/hash.js +63 -0
- package/dist/cli/lib/migrations.d.ts +6 -0
- package/dist/cli/lib/migrations.js +17 -0
- package/dist/client/index.d.ts +13 -0
- package/dist/client/index.js +34 -0
- package/dist/core/nomba_api/banks.d.ts +14 -0
- package/dist/core/nomba_api/banks.js +0 -0
- package/dist/core/nomba_api/charge-tokenized-card.d.ts +33 -0
- package/dist/core/nomba_api/charge-tokenized-card.js +0 -0
- package/dist/core/nomba_api/checkout.d.ts +44 -0
- package/dist/core/nomba_api/checkout.js +0 -0
- package/dist/core/nomba_api/get_checkout.d.ts +57 -0
- package/dist/core/nomba_api/get_checkout.js +0 -0
- package/dist/core/nomba_api/index.d.ts +313 -0
- package/dist/core/nomba_api/index.js +179 -0
- package/dist/core/nomba_api/lib/utils.d.ts +235 -0
- package/dist/core/nomba_api/lib/utils.js +313 -0
- package/dist/core/nomba_api/list-tokenized-cards.d.ts +24 -0
- package/dist/core/nomba_api/list-tokenized-cards.js +0 -0
- package/dist/core/nomba_api/token-manager/index.d.ts +51 -0
- package/dist/core/nomba_api/token-manager/index.js +109 -0
- package/dist/core/pg_db/index.d.ts +108 -0
- package/dist/core/pg_db/index.js +76 -0
- package/dist/core/pg_db/migrations/20260703085901_wealthy_blacklash/migration.sql +120 -0
- package/dist/core/pg_db/migrations/20260703085901_wealthy_blacklash/snapshot.json +1616 -0
- package/dist/core/pg_db/relations.d.ts +46 -0
- package/dist/core/pg_db/relations.js +83 -0
- package/dist/core/pg_db/schema.d.ts +1138 -0
- package/dist/core/pg_db/schema.js +124 -0
- package/dist/endpoints/customers/api.js +51 -0
- package/dist/endpoints/entitlements/api.js +42 -0
- package/dist/endpoints/routes.d.ts +15 -0
- package/dist/endpoints/routes.js +15 -0
- package/dist/endpoints/subscriptions/api.js +263 -0
- package/dist/endpoints/subscriptions/utils.js +105 -0
- package/dist/endpoints/webhooks/invoice/api.js +28 -0
- package/dist/endpoints/webhooks/nomba/api.js +76 -0
- package/dist/endpoints/webhooks/nomba/utils.js +36 -0
- package/dist/index.d.ts +204 -0
- package/dist/index.js +175 -0
- package/dist/lib/utils.d.ts +21 -0
- package/dist/lib/utils.js +41 -0
- package/dist/node_modules/.pnpm/@better-fetch_fetch@1.3.1/node_modules/@better-fetch/fetch/dist/index.js +475 -0
- package/dist/package.js +4 -0
- package/dist/queue/backends/pglite/backend.d.ts +43 -0
- package/dist/queue/backends/pglite/backend.js +33 -0
- package/dist/queue/backends/pglite/index.d.ts +4 -0
- package/dist/queue/backends/pglite/index.js +4 -0
- package/dist/queue/backends/pglite/migrations/schema.d.ts +4 -0
- package/dist/queue/backends/pglite/migrations/schema.js +37 -0
- package/dist/queue/backends/pglite/notification-channel.d.ts +17 -0
- package/dist/queue/backends/pglite/notification-channel.js +61 -0
- package/dist/queue/backends/pglite/repository.d.ts +38 -0
- package/dist/queue/backends/pglite/repository.js +299 -0
- package/dist/queue/backends/redis/index.d.ts +7 -0
- package/dist/queue/backends/redis/index.js +1 -0
- package/dist/queue/client/index.d.ts +12 -0
- package/dist/queue/client/index.js +31 -0
- package/dist/queue/endpoints/api.d.ts +53 -0
- package/dist/queue/endpoints/api.js +45 -0
- package/dist/queue/endpoints/routes.d.ts +32 -0
- package/dist/queue/endpoints/routes.js +5 -0
- package/dist/queue/init.d.ts +27 -0
- package/dist/queue/init.js +31 -0
- package/dist/queue/lib/billing.d.ts +25 -0
- package/dist/queue/lib/billing.js +87 -0
- package/dist/queue/lib/utils.d.ts +30 -0
- package/dist/queue/lib/utils.js +35 -0
- package/package.json +71 -0
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { __exportAll } from "../../_virtual/_rolldown/runtime.js";
|
|
2
|
+
import { bigint, index, integer, jsonb, pgEnum, pgTable, text, timestamp, uniqueIndex, uuid } from "drizzle-orm/pg-core";
|
|
3
|
+
//#region core/pg_db/schema.ts
|
|
4
|
+
var schema_exports = /* @__PURE__ */ __exportAll({
|
|
5
|
+
customers: () => customers,
|
|
6
|
+
entitlements: () => entitlements,
|
|
7
|
+
featureTypeEnum: () => featureTypeEnum,
|
|
8
|
+
features: () => features,
|
|
9
|
+
invoiceStatusEnum: () => invoiceStatusEnum,
|
|
10
|
+
invoices: () => invoices,
|
|
11
|
+
paymentMethods: () => paymentMethods,
|
|
12
|
+
productFeatures: () => productFeatures,
|
|
13
|
+
products: () => products,
|
|
14
|
+
subscriptionStatusEnum: () => subscriptionStatusEnum,
|
|
15
|
+
subscriptions: () => subscriptions
|
|
16
|
+
});
|
|
17
|
+
const createdAt = timestamp("created_at").notNull().$defaultFn(() => /* @__PURE__ */ new Date());
|
|
18
|
+
const updatedAt = timestamp("updated_at").notNull().$defaultFn(() => /* @__PURE__ */ new Date()).$onUpdateFn(() => /* @__PURE__ */ new Date());
|
|
19
|
+
const products = pgTable("nomkit_product", {
|
|
20
|
+
internalId: uuid("internal_id").defaultRandom().primaryKey(),
|
|
21
|
+
id: text("id").notNull(),
|
|
22
|
+
label: text("label").notNull(),
|
|
23
|
+
hash: text("product_hash").notNull(),
|
|
24
|
+
priceAmount: integer("price_amount"),
|
|
25
|
+
priceInterval: text("product_interval"),
|
|
26
|
+
version: integer("version").notNull().default(1),
|
|
27
|
+
createdAt,
|
|
28
|
+
updatedAt
|
|
29
|
+
}, (table) => [index("product_idx").on(table.id), uniqueIndex("product_hash_version_unique").on(table.id, table.hash, table.version)]);
|
|
30
|
+
const features = pgTable("nomkit_feature", {
|
|
31
|
+
internalId: uuid("internal_id").defaultRandom().primaryKey(),
|
|
32
|
+
id: text("id").notNull(),
|
|
33
|
+
label: text("label").notNull(),
|
|
34
|
+
createdAt,
|
|
35
|
+
updatedAt
|
|
36
|
+
}, (table) => [index("feature_idx").on(table.internalId), uniqueIndex("feature_unique").on(table.id)]);
|
|
37
|
+
const featureTypeEnum = pgEnum("feature_type", ["boolean", "metered"]);
|
|
38
|
+
const productFeatures = pgTable("nomkit_product_features", {
|
|
39
|
+
internalId: uuid("id").defaultRandom().primaryKey(),
|
|
40
|
+
featureInternalId: uuid("feature_internal_id").notNull().references(() => features.internalId),
|
|
41
|
+
type: featureTypeEnum("type").notNull(),
|
|
42
|
+
limit: bigint("limit", { mode: "bigint" }),
|
|
43
|
+
resetInterval: text("reset_interval"),
|
|
44
|
+
hash: text("feature_hash").notNull(),
|
|
45
|
+
productId: text("product_id").notNull(),
|
|
46
|
+
productVersion: integer("product_version").notNull(),
|
|
47
|
+
createdAt,
|
|
48
|
+
updatedAt
|
|
49
|
+
}, (table) => [index("project_feature_idx").on(table.internalId), uniqueIndex("product_feature_unique_version").on(table.internalId, table.productId, table.featureInternalId, table.productVersion)]);
|
|
50
|
+
const customers = pgTable("nomkit_customer", {
|
|
51
|
+
id: uuid("id").defaultRandom().primaryKey(),
|
|
52
|
+
name: text("name"),
|
|
53
|
+
email: text("email").unique().notNull(),
|
|
54
|
+
metadata: jsonb("metadata").$type(),
|
|
55
|
+
externalId: text("external_id").unique(),
|
|
56
|
+
createdAt,
|
|
57
|
+
updatedAt
|
|
58
|
+
}, (table) => [index("customer_created_at_id_idx").on(table.createdAt, table.id)]);
|
|
59
|
+
const paymentMethods = pgTable("nomkit_payment_method", {
|
|
60
|
+
id: uuid("id").defaultRandom().primaryKey(),
|
|
61
|
+
customerId: uuid("customer_id").notNull().references(() => customers.id, { onDelete: "cascade" }),
|
|
62
|
+
tokenKey: text("token_key").notNull().unique(),
|
|
63
|
+
email: text("email").notNull(),
|
|
64
|
+
cardType: text("card_type").notNull(),
|
|
65
|
+
cardPan: text("card_pan").notNull(),
|
|
66
|
+
tokenExpirationDate: text("token_expiration_date").notNull(),
|
|
67
|
+
createdAt,
|
|
68
|
+
updatedAt
|
|
69
|
+
});
|
|
70
|
+
const subscriptionStatusEnum = pgEnum("subscription_status", [
|
|
71
|
+
"active",
|
|
72
|
+
"cancelled",
|
|
73
|
+
"past_due",
|
|
74
|
+
"pending",
|
|
75
|
+
"scheduled"
|
|
76
|
+
]);
|
|
77
|
+
const subscriptions = pgTable("nomkit_subscription", {
|
|
78
|
+
id: uuid("id").defaultRandom().primaryKey(),
|
|
79
|
+
customerId: uuid("customer_id").notNull().references(() => customers.id, { onDelete: "cascade" }),
|
|
80
|
+
productInternalId: uuid("product_internal_id").notNull().references(() => products.internalId, { onDelete: "cascade" }),
|
|
81
|
+
paymentMethodId: uuid("payment_method_id").references(() => paymentMethods.id),
|
|
82
|
+
nextSubscriptionId: uuid("next_subscription_id").references(() => subscriptions.id, { onDelete: "set null" }),
|
|
83
|
+
status: subscriptionStatusEnum("status").notNull().default("active"),
|
|
84
|
+
currentPeriodStart: timestamp("current_period_start"),
|
|
85
|
+
currentPeriodEnd: timestamp("current_period_end"),
|
|
86
|
+
createdAt,
|
|
87
|
+
updatedAt,
|
|
88
|
+
endedAt: timestamp("ended_at")
|
|
89
|
+
}, (table) => [index("subscription_customer_idx").on(table.customerId)]);
|
|
90
|
+
const entitlements = pgTable("nomkit_entitlement", {
|
|
91
|
+
id: uuid("id").defaultRandom().primaryKey(),
|
|
92
|
+
subscriptionId: uuid("subscription_id").notNull().references(() => subscriptions.id, { onDelete: "cascade" }),
|
|
93
|
+
customerId: uuid("customer_id").notNull().references(() => customers.id, { onDelete: "cascade" }),
|
|
94
|
+
featureInternalId: uuid("feature_internal_id").notNull().references(() => features.internalId, { onDelete: "cascade" }),
|
|
95
|
+
limit: bigint("limit", { mode: "bigint" }),
|
|
96
|
+
remaining: bigint("remaining", { mode: "bigint" }),
|
|
97
|
+
resetAt: timestamp("reset_at"),
|
|
98
|
+
createdAt,
|
|
99
|
+
updatedAt
|
|
100
|
+
}, (table) => [
|
|
101
|
+
index("entitlement_customer_idx").on(table.customerId),
|
|
102
|
+
index("entitlement_subscription_idx").on(table.subscriptionId),
|
|
103
|
+
uniqueIndex("subscription_feature_unique").on(table.subscriptionId, table.featureInternalId)
|
|
104
|
+
]);
|
|
105
|
+
const invoiceStatusEnum = pgEnum("invoice_status", [
|
|
106
|
+
"paid",
|
|
107
|
+
"failed",
|
|
108
|
+
"void",
|
|
109
|
+
"pending"
|
|
110
|
+
]);
|
|
111
|
+
const invoices = pgTable("nomkit_invoice", {
|
|
112
|
+
id: uuid("id").defaultRandom().primaryKey(),
|
|
113
|
+
customerId: uuid("customer_id").notNull().references(() => customers.id),
|
|
114
|
+
subscriptionId: uuid("subscription_id").notNull().references(() => subscriptions.id),
|
|
115
|
+
status: invoiceStatusEnum("status").notNull().default("pending"),
|
|
116
|
+
amount: integer("amount").notNull(),
|
|
117
|
+
currency: text("currency").notNull(),
|
|
118
|
+
periodStart: timestamp("period_start"),
|
|
119
|
+
periodEnd: timestamp("period_end"),
|
|
120
|
+
createdAt,
|
|
121
|
+
updatedAt
|
|
122
|
+
}, (table) => [uniqueIndex("invoice_period_unique").on(table.subscriptionId, table.periodStart, table.periodEnd)]);
|
|
123
|
+
//#endregion
|
|
124
|
+
export { customers, entitlements, featureTypeEnum, features, invoiceStatusEnum, invoices, paymentMethods, productFeatures, products, schema_exports, subscriptionStatusEnum, subscriptions };
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { __exportAll } from "../../_virtual/_rolldown/runtime.js";
|
|
2
|
+
import { defineNomkitMethod } from "../../lib/utils.js";
|
|
3
|
+
import { z as z$1 } from "zod";
|
|
4
|
+
//#region endpoints/customers/api.ts
|
|
5
|
+
var api_exports = /* @__PURE__ */ __exportAll({
|
|
6
|
+
deleteCustomer: () => deleteCustomer,
|
|
7
|
+
getCustomer: () => getCustomer,
|
|
8
|
+
listCustomers: () => listCustomers,
|
|
9
|
+
upsertCustomer: () => upsertCustomer
|
|
10
|
+
});
|
|
11
|
+
const getCustomer = defineNomkitMethod({
|
|
12
|
+
input: z$1.object({
|
|
13
|
+
customerId: z$1.string().optional(),
|
|
14
|
+
externalId: z$1.string().optional()
|
|
15
|
+
}),
|
|
16
|
+
route: { path: "/customers/get" }
|
|
17
|
+
}, async (ctx) => {
|
|
18
|
+
ctx.nomkit.database;
|
|
19
|
+
return {};
|
|
20
|
+
});
|
|
21
|
+
const upsertCustomer = defineNomkitMethod({
|
|
22
|
+
input: z$1.object({
|
|
23
|
+
name: z$1.string().optional(),
|
|
24
|
+
externalId: z$1.string().min(1).max(60).optional(),
|
|
25
|
+
email: z$1.email().min(1).max(60),
|
|
26
|
+
metadata: z$1.union([z$1.record(z$1.string(), z$1.string()), z$1.null()]).default(null)
|
|
27
|
+
}),
|
|
28
|
+
route: { path: "/customers/upsert" }
|
|
29
|
+
}, async (ctx) => {
|
|
30
|
+
ctx.nomkit.database;
|
|
31
|
+
return true;
|
|
32
|
+
});
|
|
33
|
+
const listCustomers = defineNomkitMethod({
|
|
34
|
+
input: z$1.object({
|
|
35
|
+
cursor: z$1.string().optional(),
|
|
36
|
+
limit: z$1.number().default(20)
|
|
37
|
+
}),
|
|
38
|
+
route: { path: "/customers/list" }
|
|
39
|
+
}, async (ctx) => {
|
|
40
|
+
ctx.nomkit.database;
|
|
41
|
+
return [];
|
|
42
|
+
});
|
|
43
|
+
const deleteCustomer = defineNomkitMethod({
|
|
44
|
+
input: z$1.object({ customerId: z$1.string().optional() }),
|
|
45
|
+
route: { path: "/customers/delete" }
|
|
46
|
+
}, async (ctx) => {
|
|
47
|
+
ctx.nomkit.database;
|
|
48
|
+
return true;
|
|
49
|
+
});
|
|
50
|
+
//#endregion
|
|
51
|
+
export { api_exports, deleteCustomer, getCustomer, listCustomers, upsertCustomer };
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { __exportAll } from "../../_virtual/_rolldown/runtime.js";
|
|
2
|
+
import { defineNomkitMethod } from "../../lib/utils.js";
|
|
3
|
+
import { z as z$1 } from "zod";
|
|
4
|
+
//#region endpoints/entitlements/api.ts
|
|
5
|
+
var api_exports = /* @__PURE__ */ __exportAll({
|
|
6
|
+
checkEntitlements: () => checkEntitlements,
|
|
7
|
+
consumeEntitlements: () => consumeEntitlements
|
|
8
|
+
});
|
|
9
|
+
const checkEntitlements = defineNomkitMethod({
|
|
10
|
+
input: z$1.object({
|
|
11
|
+
customerId: z$1.string(),
|
|
12
|
+
featureId: z$1.string()
|
|
13
|
+
}),
|
|
14
|
+
route: { path: "/entitlements/check" }
|
|
15
|
+
}, async (ctx) => {
|
|
16
|
+
return ctx.input.featureId === "bluetick" ? { allowed: true } : {
|
|
17
|
+
allowed: true,
|
|
18
|
+
balance: {
|
|
19
|
+
limit: 100,
|
|
20
|
+
remaining: 99,
|
|
21
|
+
resetAt: (/* @__PURE__ */ new Date()).toUTCString(),
|
|
22
|
+
unlimited: false
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
});
|
|
26
|
+
const consumeEntitlements = defineNomkitMethod({
|
|
27
|
+
input: z$1.object({
|
|
28
|
+
customerId: z$1.string(),
|
|
29
|
+
featureId: z$1.string(),
|
|
30
|
+
amount: z$1.number()
|
|
31
|
+
}),
|
|
32
|
+
route: { path: "/entitlements/consume" }
|
|
33
|
+
}, async (ctx) => {
|
|
34
|
+
return {
|
|
35
|
+
featureId: ctx.input.featureId,
|
|
36
|
+
balance: 0,
|
|
37
|
+
limit: 100,
|
|
38
|
+
unlimited: false
|
|
39
|
+
};
|
|
40
|
+
});
|
|
41
|
+
//#endregion
|
|
42
|
+
export { api_exports, checkEntitlements, consumeEntitlements };
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
//#region endpoints/routes.d.ts
|
|
2
|
+
declare const nomkitWebhooks: {
|
|
3
|
+
readonly invoiceWebhook: import("better-call").Endpoint<"/invoice-webhook", "POST", {
|
|
4
|
+
billing: Record<string, never>;
|
|
5
|
+
signature: string;
|
|
6
|
+
status: "success" | "cancelled" | "retrying";
|
|
7
|
+
policy: string;
|
|
8
|
+
}, Record<string, any> | undefined, [], {
|
|
9
|
+
status: string;
|
|
10
|
+
}, {
|
|
11
|
+
scope: "http";
|
|
12
|
+
}, undefined>;
|
|
13
|
+
};
|
|
14
|
+
//#endregion
|
|
15
|
+
export { nomkitWebhooks };
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { api_exports } from "./customers/api.js";
|
|
2
|
+
import { api_exports as api_exports$1 } from "./entitlements/api.js";
|
|
3
|
+
import { api_exports as api_exports$2 } from "./subscriptions/api.js";
|
|
4
|
+
import { api_exports as api_exports$3 } from "./webhooks/invoice/api.js";
|
|
5
|
+
import { api_exports as api_exports$4 } from "./webhooks/nomba/api.js";
|
|
6
|
+
({ ...api_exports$3 });
|
|
7
|
+
const routes = {
|
|
8
|
+
...api_exports$2,
|
|
9
|
+
...api_exports$1,
|
|
10
|
+
...api_exports,
|
|
11
|
+
...api_exports$4,
|
|
12
|
+
...api_exports$3
|
|
13
|
+
};
|
|
14
|
+
//#endregion
|
|
15
|
+
export { routes };
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import { __exportAll } from "../../_virtual/_rolldown/runtime.js";
|
|
2
|
+
import { subscriptions } from "../../core/pg_db/schema.js";
|
|
3
|
+
import { defineNomkitMethod, getOrderReference } from "../../lib/utils.js";
|
|
4
|
+
import { buildBaseCheckoutMetadata, classifyChange, computeUpgradeProration, getCurrentSubscription, getScheduledPeriodBounds, isFreePlan, persistProrationOnCustomer } from "./utils.js";
|
|
5
|
+
import { z as z$1 } from "zod";
|
|
6
|
+
import { and, eq } from "drizzle-orm";
|
|
7
|
+
//#region endpoints/subscriptions/api.ts
|
|
8
|
+
var api_exports = /* @__PURE__ */ __exportAll({
|
|
9
|
+
mockSubscribe: () => mockSubscribe,
|
|
10
|
+
subscribe: () => subscribe
|
|
11
|
+
});
|
|
12
|
+
const SubscribeSchema = z$1.object({
|
|
13
|
+
customerId: z$1.string().min(1).max(60),
|
|
14
|
+
planID: z$1.string().min(1).max(60),
|
|
15
|
+
successURL: z$1.url().min(1).max(60),
|
|
16
|
+
cancelURL: z$1.string().optional()
|
|
17
|
+
});
|
|
18
|
+
const subscribe = defineNomkitMethod({
|
|
19
|
+
input: SubscribeSchema,
|
|
20
|
+
route: { path: "/subscribe" }
|
|
21
|
+
}, async (ctx) => {
|
|
22
|
+
const db = ctx.nomkit.database;
|
|
23
|
+
const input = ctx.input;
|
|
24
|
+
const nombaAPI = ctx.nomkit.nomba;
|
|
25
|
+
const customer = await db.query.customers.findFirst({ where: { id: input.customerId } });
|
|
26
|
+
if (!customer) {
|
|
27
|
+
console.log("customer not found");
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
const plan = await db.query.products.findFirst({
|
|
31
|
+
where: { id: input.planID },
|
|
32
|
+
orderBy: (products, { desc }) => [desc(products.version)]
|
|
33
|
+
});
|
|
34
|
+
if (!plan) {
|
|
35
|
+
console.log("Plan not found");
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
const currentSubscription = await getCurrentSubscription(db, customer.id);
|
|
39
|
+
if (!currentSubscription) return startNewSubscription(db, customer, plan, nombaAPI);
|
|
40
|
+
if (currentSubscription.productInternalId === plan.internalId) {
|
|
41
|
+
console.log("Already on this plan");
|
|
42
|
+
return { checkoutURL: null };
|
|
43
|
+
}
|
|
44
|
+
const previousPlan = await db.query.products.findFirst({ where: { internalId: currentSubscription.productInternalId } });
|
|
45
|
+
if (!previousPlan) {
|
|
46
|
+
console.log("Failed to query previous plan");
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
return switchSubscriptionPlan(db, {
|
|
50
|
+
customer,
|
|
51
|
+
plan,
|
|
52
|
+
previousPlan,
|
|
53
|
+
currentSubscription,
|
|
54
|
+
nomba: nombaAPI
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
const mockSubscribe = defineNomkitMethod({
|
|
58
|
+
input: SubscribeSchema,
|
|
59
|
+
route: { path: "/mmock-subscribe" }
|
|
60
|
+
}, async (ctx) => {
|
|
61
|
+
const db = ctx.nomkit.database;
|
|
62
|
+
const input = ctx.input;
|
|
63
|
+
const nombaAPI = ctx.nomkit.nomba;
|
|
64
|
+
const customer = await db.query.customers.findFirst({ where: { id: input.customerId } });
|
|
65
|
+
if (!customer) {
|
|
66
|
+
console.log("customer not found");
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
const plan = await db.query.products.findFirst({
|
|
70
|
+
where: { id: input.planID },
|
|
71
|
+
orderBy: (products, { desc }) => [desc(products.version)]
|
|
72
|
+
});
|
|
73
|
+
if (!plan) {
|
|
74
|
+
console.log("Plan not found");
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
const currentSubscription = await getCurrentSubscription(db, customer.id);
|
|
78
|
+
if (!currentSubscription) return startNewSubscription(db, customer, plan, nombaAPI);
|
|
79
|
+
if (currentSubscription.productInternalId === plan.internalId) {
|
|
80
|
+
console.log("Already on this plan");
|
|
81
|
+
return { checkoutURL: null };
|
|
82
|
+
}
|
|
83
|
+
const previousPlan = await db.query.products.findFirst({ where: { internalId: currentSubscription.productInternalId } });
|
|
84
|
+
if (!previousPlan) {
|
|
85
|
+
console.log("Failed to query previous plan");
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
return switchSubscriptionPlan(db, {
|
|
89
|
+
customer,
|
|
90
|
+
plan,
|
|
91
|
+
previousPlan,
|
|
92
|
+
currentSubscription,
|
|
93
|
+
nomba: nombaAPI
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
async function startNewSubscription(db, customer, plan, nomba) {
|
|
97
|
+
const newSubscription = {
|
|
98
|
+
customerId: customer.id,
|
|
99
|
+
productInternalId: plan.internalId,
|
|
100
|
+
status: "pending"
|
|
101
|
+
};
|
|
102
|
+
const [subscription] = await db.insert(subscriptions).values(newSubscription).returning();
|
|
103
|
+
const amount = plan.priceAmount;
|
|
104
|
+
if (!amount) {
|
|
105
|
+
await db.update(subscriptions).set({
|
|
106
|
+
status: "active",
|
|
107
|
+
currentPeriodStart: /* @__PURE__ */ new Date()
|
|
108
|
+
}).where(eq(subscriptions.id, subscription.id));
|
|
109
|
+
return { checkoutURL: null };
|
|
110
|
+
}
|
|
111
|
+
const reference = getOrderReference();
|
|
112
|
+
const checkout = await nomba.checkOut({
|
|
113
|
+
amount: amount.toString(),
|
|
114
|
+
customerEmail: customer.email,
|
|
115
|
+
reference,
|
|
116
|
+
successURL: "input.successURL",
|
|
117
|
+
metadata: buildBaseCheckoutMetadata({
|
|
118
|
+
reference,
|
|
119
|
+
subscriptionId: subscription.id,
|
|
120
|
+
customer,
|
|
121
|
+
plan,
|
|
122
|
+
kind: "new"
|
|
123
|
+
})
|
|
124
|
+
});
|
|
125
|
+
console.log("Checkout for brand new subscription");
|
|
126
|
+
return { checkoutURL: checkout.checkoutLink };
|
|
127
|
+
}
|
|
128
|
+
async function activateSwitchedSubscriptionWithCredit(db, params) {
|
|
129
|
+
const { subscriptionId, previousSubscriptionId, now, periodEnd } = params;
|
|
130
|
+
await db.update(subscriptions).set({
|
|
131
|
+
status: "active",
|
|
132
|
+
currentPeriodStart: new Date(now.epochMilliseconds),
|
|
133
|
+
currentPeriodEnd: new Date(periodEnd.epochMilliseconds)
|
|
134
|
+
}).where(eq(subscriptions.id, subscriptionId));
|
|
135
|
+
await db.update(subscriptions).set({
|
|
136
|
+
status: "cancelled",
|
|
137
|
+
endedAt: new Date(now.epochMilliseconds)
|
|
138
|
+
}).where(eq(subscriptions.id, previousSubscriptionId));
|
|
139
|
+
}
|
|
140
|
+
async function switchSubscriptionPlan(db, params) {
|
|
141
|
+
const { customer, plan, previousPlan, currentSubscription } = params;
|
|
142
|
+
if (isFreePlan(previousPlan) || !currentSubscription.currentPeriodEnd) return switchSubscriptionImmediately(db, {
|
|
143
|
+
customer,
|
|
144
|
+
plan,
|
|
145
|
+
previousPlan,
|
|
146
|
+
currentSubscription,
|
|
147
|
+
prorate: false,
|
|
148
|
+
nomba: params.nomba
|
|
149
|
+
});
|
|
150
|
+
if (classifyChange(previousPlan, plan) === "upgrade") return switchSubscriptionImmediately(db, {
|
|
151
|
+
customer,
|
|
152
|
+
plan,
|
|
153
|
+
previousPlan,
|
|
154
|
+
currentSubscription,
|
|
155
|
+
prorate: true,
|
|
156
|
+
nomba: params.nomba
|
|
157
|
+
});
|
|
158
|
+
return scheduleSubscriptionSwitch(db, {
|
|
159
|
+
customer,
|
|
160
|
+
plan,
|
|
161
|
+
previousPlan,
|
|
162
|
+
currentSubscription
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
async function switchSubscriptionImmediately(db, params) {
|
|
166
|
+
const { customer, plan, previousPlan, currentSubscription, prorate } = params;
|
|
167
|
+
if (params.previousPlan) await db.update(subscriptions).set({ status: "cancelled" }).where(and(eq(subscriptions.productInternalId, params.previousPlan.internalId), eq(subscriptions.status, "active")));
|
|
168
|
+
if (currentSubscription.nextSubscriptionId) {
|
|
169
|
+
await db.delete(subscriptions).where(eq(subscriptions.id, currentSubscription.nextSubscriptionId));
|
|
170
|
+
await db.update(subscriptions).set({ nextSubscriptionId: null }).where(eq(subscriptions.id, currentSubscription.id));
|
|
171
|
+
}
|
|
172
|
+
const newSubscription = {
|
|
173
|
+
customerId: customer.id,
|
|
174
|
+
productInternalId: plan.internalId,
|
|
175
|
+
status: isFreePlan(plan) ? "active" : "pending"
|
|
176
|
+
};
|
|
177
|
+
const [subscription] = await db.insert(subscriptions).values(newSubscription).returning();
|
|
178
|
+
if (isFreePlan(plan)) {
|
|
179
|
+
await db.update(subscriptions).set({ currentPeriodStart: /* @__PURE__ */ new Date() }).where(eq(subscriptions.id, subscription.id));
|
|
180
|
+
await db.update(subscriptions).set({
|
|
181
|
+
status: "cancelled",
|
|
182
|
+
endedAt: /* @__PURE__ */ new Date()
|
|
183
|
+
}).where(eq(subscriptions.id, currentSubscription.id));
|
|
184
|
+
return { checkoutURL: null };
|
|
185
|
+
}
|
|
186
|
+
if (!prorate) {
|
|
187
|
+
const reference = getOrderReference();
|
|
188
|
+
return { checkoutURL: (await params.nomba.checkOut({
|
|
189
|
+
amount: (plan.priceAmount ?? 0).toString(),
|
|
190
|
+
customerEmail: customer.email,
|
|
191
|
+
reference,
|
|
192
|
+
successURL: "input.successURL",
|
|
193
|
+
metadata: {
|
|
194
|
+
...buildBaseCheckoutMetadata({
|
|
195
|
+
reference,
|
|
196
|
+
subscriptionId: subscription.id,
|
|
197
|
+
customer,
|
|
198
|
+
plan,
|
|
199
|
+
kind: "new"
|
|
200
|
+
}),
|
|
201
|
+
"nomkit.subscription.previous_subscription_id": currentSubscription.id
|
|
202
|
+
}
|
|
203
|
+
})).checkoutLink };
|
|
204
|
+
}
|
|
205
|
+
const existingMetadata = customer.metadata ?? {};
|
|
206
|
+
const proration = computeUpgradeProration({
|
|
207
|
+
currentSubscription,
|
|
208
|
+
previousPlan,
|
|
209
|
+
plan,
|
|
210
|
+
existingBalance: Number(existingMetadata?.creditBalance ?? "0") || 0
|
|
211
|
+
});
|
|
212
|
+
await persistProrationOnCustomer(db, customer, previousPlan, plan, proration);
|
|
213
|
+
if (proration.chargeAmount <= 0) {
|
|
214
|
+
await activateSwitchedSubscriptionWithCredit(db, {
|
|
215
|
+
subscriptionId: subscription.id,
|
|
216
|
+
previousSubscriptionId: currentSubscription.id,
|
|
217
|
+
plan,
|
|
218
|
+
now: proration.now,
|
|
219
|
+
periodEnd: proration.periodEnd
|
|
220
|
+
});
|
|
221
|
+
console.log("Upgrade applied using existing credit balance");
|
|
222
|
+
return { checkoutURL: null };
|
|
223
|
+
}
|
|
224
|
+
const reference = getOrderReference();
|
|
225
|
+
const checkoutMetadata = {
|
|
226
|
+
...buildBaseCheckoutMetadata({
|
|
227
|
+
reference,
|
|
228
|
+
subscriptionId: subscription.id,
|
|
229
|
+
customer,
|
|
230
|
+
plan,
|
|
231
|
+
kind: "upgrade"
|
|
232
|
+
}),
|
|
233
|
+
"nomkit.subscription.previous_subscription_id": currentSubscription.id,
|
|
234
|
+
"nomkit.subscription.previous_internalPlanId": previousPlan.internalId,
|
|
235
|
+
"nomkit.subscription.prorated_credit_applied": proration.totalCredit.toString()
|
|
236
|
+
};
|
|
237
|
+
return { checkoutURL: (await params.nomba.checkOut({
|
|
238
|
+
amount: proration.chargeAmount.toString(),
|
|
239
|
+
customerEmail: customer.email,
|
|
240
|
+
reference,
|
|
241
|
+
successURL: "input.successURL",
|
|
242
|
+
metadata: checkoutMetadata
|
|
243
|
+
})).checkoutLink };
|
|
244
|
+
}
|
|
245
|
+
async function scheduleSubscriptionSwitch(db, params) {
|
|
246
|
+
const { customer, plan, previousPlan, currentSubscription } = params;
|
|
247
|
+
const periodEnd = currentSubscription.currentPeriodEnd;
|
|
248
|
+
if (currentSubscription.nextSubscriptionId) await db.delete(subscriptions).where(eq(subscriptions.id, currentSubscription.nextSubscriptionId));
|
|
249
|
+
const { start, end } = getScheduledPeriodBounds(periodEnd, plan);
|
|
250
|
+
const newSubscription = {
|
|
251
|
+
customerId: customer.id,
|
|
252
|
+
productInternalId: plan.internalId,
|
|
253
|
+
status: "scheduled",
|
|
254
|
+
currentPeriodStart: start,
|
|
255
|
+
currentPeriodEnd: end
|
|
256
|
+
};
|
|
257
|
+
const [scheduled] = await db.insert(subscriptions).values(newSubscription).returning();
|
|
258
|
+
await db.update(subscriptions).set({ nextSubscriptionId: scheduled.id }).where(eq(subscriptions.id, currentSubscription.id));
|
|
259
|
+
console.log(`Scheduled switch from plan ${previousPlan.id} to ${plan.id}, effective ${periodEnd.toISOString()}`);
|
|
260
|
+
return { checkoutURL: null };
|
|
261
|
+
}
|
|
262
|
+
//#endregion
|
|
263
|
+
export { api_exports, mockSubscribe, subscribe };
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { customers, subscriptions } from "../../core/pg_db/schema.js";
|
|
2
|
+
import ms from "ms";
|
|
3
|
+
import { Temporal } from "temporal-polyfill";
|
|
4
|
+
import { and, desc, eq, inArray } from "drizzle-orm";
|
|
5
|
+
//#region endpoints/subscriptions/utils.ts
|
|
6
|
+
function getPeriodEnd(args) {
|
|
7
|
+
const periodStart = Temporal.Instant.from(args.now);
|
|
8
|
+
const milliseconds = ms(args.interval);
|
|
9
|
+
if (typeof milliseconds !== "number" || Number.isNaN(milliseconds)) throw new Error("You have an invalid interval");
|
|
10
|
+
if (milliseconds < 0) throw new Error("timestamp cannot include negative intervals");
|
|
11
|
+
return periodStart.add(Temporal.Duration.from({ milliseconds }));
|
|
12
|
+
}
|
|
13
|
+
function getScheduledPeriodBounds(periodEnd, plan) {
|
|
14
|
+
if (isFreePlan(plan)) return {
|
|
15
|
+
start: periodEnd,
|
|
16
|
+
end: null
|
|
17
|
+
};
|
|
18
|
+
const scheduledEnd = getPeriodEnd({
|
|
19
|
+
now: Temporal.Instant.fromEpochMilliseconds(periodEnd.getTime()),
|
|
20
|
+
interval: plan.priceInterval
|
|
21
|
+
});
|
|
22
|
+
return {
|
|
23
|
+
start: periodEnd,
|
|
24
|
+
end: new Date(scheduledEnd.epochMilliseconds)
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
function isFreePlan(plan) {
|
|
28
|
+
return plan.priceAmount === null && plan.priceInterval === null;
|
|
29
|
+
}
|
|
30
|
+
function classifyChange(previousPlan, plan) {
|
|
31
|
+
const oldAmount = previousPlan.priceAmount ?? 0;
|
|
32
|
+
const newAmount = plan.priceAmount ?? 0;
|
|
33
|
+
if (newAmount > oldAmount) return "upgrade";
|
|
34
|
+
if (newAmount < oldAmount) return "downgrade";
|
|
35
|
+
return "lateral";
|
|
36
|
+
}
|
|
37
|
+
async function getCurrentSubscription(db, customerId) {
|
|
38
|
+
const [currentSubscription] = await db.select().from(subscriptions).where(and(eq(subscriptions.customerId, customerId), inArray(subscriptions.status, [
|
|
39
|
+
"active",
|
|
40
|
+
"pending",
|
|
41
|
+
"scheduled"
|
|
42
|
+
]))).orderBy(desc(subscriptions.createdAt)).limit(1);
|
|
43
|
+
return currentSubscription ?? null;
|
|
44
|
+
}
|
|
45
|
+
function buildBaseCheckoutMetadata(params) {
|
|
46
|
+
const { reference, subscriptionId, customer, plan, kind } = params;
|
|
47
|
+
return {
|
|
48
|
+
"nomkit.order_reference": reference,
|
|
49
|
+
"nomkit.subscription.id": subscriptionId,
|
|
50
|
+
"nomkit.subscription.customer_id": customer.id,
|
|
51
|
+
"nomkit.subscription.customer_email": customer.email,
|
|
52
|
+
"nomkit.subscription.internalPlanId": plan.internalId,
|
|
53
|
+
"nomkit.subscription.kind": kind
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
function resolveCurrentPeriod(currentSubscription, plan, now) {
|
|
57
|
+
const periodStart = currentSubscription.currentPeriodStart ? Temporal.Instant.fromEpochMilliseconds(currentSubscription.currentPeriodStart.getTime()) : now;
|
|
58
|
+
let periodEnd;
|
|
59
|
+
if (currentSubscription.currentPeriodEnd) periodEnd = Temporal.Instant.fromEpochMilliseconds(currentSubscription.currentPeriodEnd.getTime());
|
|
60
|
+
else periodEnd = getPeriodEnd({
|
|
61
|
+
now,
|
|
62
|
+
interval: plan.priceInterval
|
|
63
|
+
});
|
|
64
|
+
return {
|
|
65
|
+
periodStart,
|
|
66
|
+
periodEnd
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
function computeUpgradeProration(params) {
|
|
70
|
+
const { currentSubscription, previousPlan, plan, existingBalance } = params;
|
|
71
|
+
const oldAmount = previousPlan.priceAmount ?? 0;
|
|
72
|
+
const newAmount = plan.priceAmount ?? 0;
|
|
73
|
+
const now = Temporal.Now.instant();
|
|
74
|
+
const { periodStart, periodEnd } = resolveCurrentPeriod(currentSubscription, plan, now);
|
|
75
|
+
const totalPeriodMs = Math.max(0, periodStart.until(periodEnd).total("milliseconds"));
|
|
76
|
+
const remainingMs = Math.max(0, Math.min(totalPeriodMs, now.until(periodEnd).total("milliseconds")));
|
|
77
|
+
const remainingRatio = totalPeriodMs > 0 ? remainingMs / totalPeriodMs : 0;
|
|
78
|
+
const unusedAmount = Math.round(oldAmount * remainingRatio);
|
|
79
|
+
const proratedNewAmount = Math.round(newAmount * remainingRatio);
|
|
80
|
+
const totalCredit = existingBalance + unusedAmount;
|
|
81
|
+
const amountDue = proratedNewAmount - totalCredit;
|
|
82
|
+
return {
|
|
83
|
+
now,
|
|
84
|
+
periodEnd,
|
|
85
|
+
unusedAmount,
|
|
86
|
+
proratedNewAmount,
|
|
87
|
+
totalCredit,
|
|
88
|
+
chargeAmount: amountDue > 0 ? amountDue : 0,
|
|
89
|
+
newBalance: amountDue < 0 ? Math.abs(amountDue) : 0
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
async function persistProrationOnCustomer(db, customer, previousPlan, plan, proration) {
|
|
93
|
+
const updatedMetadata = {
|
|
94
|
+
...customer.metadata ?? {},
|
|
95
|
+
creditBalance: proration.newBalance.toString(),
|
|
96
|
+
lastProrationAt: proration.now.toString(),
|
|
97
|
+
lastProrationFromPlan: previousPlan.id,
|
|
98
|
+
lastProrationToPlan: plan.id,
|
|
99
|
+
lastProrationUnusedAmount: proration.unusedAmount.toString(),
|
|
100
|
+
lastProrationChargeAmount: proration.chargeAmount.toString()
|
|
101
|
+
};
|
|
102
|
+
await db.update(customers).set({ metadata: updatedMetadata }).where(eq(customers.id, customer.id));
|
|
103
|
+
}
|
|
104
|
+
//#endregion
|
|
105
|
+
export { buildBaseCheckoutMetadata, classifyChange, computeUpgradeProration, getCurrentSubscription, getPeriodEnd, getScheduledPeriodBounds, isFreePlan, persistProrationOnCustomer, resolveCurrentPeriod };
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { __exportAll } from "../../../_virtual/_rolldown/runtime.js";
|
|
2
|
+
import { defineNomkitMethod } from "../../../lib/utils.js";
|
|
3
|
+
import { z as z$1 } from "zod";
|
|
4
|
+
//#region endpoints/webhooks/invoice/api.ts
|
|
5
|
+
var api_exports = /* @__PURE__ */ __exportAll({ invoiceWebhook: () => invoiceWebhook });
|
|
6
|
+
/**
|
|
7
|
+
* Internal: endpoint called after the queue processes a subscription billing
|
|
8
|
+
*/
|
|
9
|
+
const invoiceWebhook = defineNomkitMethod({
|
|
10
|
+
input: z$1.object({
|
|
11
|
+
billing: z$1.object({}),
|
|
12
|
+
signature: z$1.string().min(1).max(10),
|
|
13
|
+
status: z$1.enum([
|
|
14
|
+
"success",
|
|
15
|
+
"retrying",
|
|
16
|
+
"cancelled"
|
|
17
|
+
]),
|
|
18
|
+
policy: z$1.string()
|
|
19
|
+
}),
|
|
20
|
+
route: {
|
|
21
|
+
path: "/invoice-webhook",
|
|
22
|
+
requireHeaders: true
|
|
23
|
+
}
|
|
24
|
+
}, async (ctx) => {
|
|
25
|
+
return { status: "event_recieved" };
|
|
26
|
+
});
|
|
27
|
+
//#endregion
|
|
28
|
+
export { api_exports, invoiceWebhook };
|