=> {
+ const signature = headers.get("stripe-signature");
+
+ if (!signature) {
+ throw new PaymentWebhookSignatureError(
+ "The request has no Stripe-Signature header.",
+ );
+ }
+
+ let event: Stripe.Event;
+
+ try {
+ event = await stripe.webhooks.constructEventAsync(
+ body,
+ signature,
+ webhookSecret,
+ );
+ } catch (error) {
+ throw new PaymentWebhookSignatureError(
+ error instanceof Error ? error.message : undefined,
+ );
+ }
+
+ return {
+ id: event.id,
+ scope: event.livemode ? "live" : "test",
+ target: targetOf(event),
+ type: event.type,
+ };
+ },
+ },
+ };
+};
diff --git a/packages/stripe/tsconfig.build.json b/packages/stripe/tsconfig.build.json
new file mode 100644
index 0000000000..f870d0a83c
--- /dev/null
+++ b/packages/stripe/tsconfig.build.json
@@ -0,0 +1,6 @@
+{
+ "$schema": "https://json.schemastore.org/tsconfig",
+ "extends": "./tsconfig.json",
+ "include": ["src"],
+ "exclude": ["node_modules", "**/*.test.ts", "**/*.test.tsx"]
+}
diff --git a/packages/stripe/tsconfig.json b/packages/stripe/tsconfig.json
new file mode 100644
index 0000000000..17ad3f27cf
--- /dev/null
+++ b/packages/stripe/tsconfig.json
@@ -0,0 +1,20 @@
+{
+ "$schema": "https://json.schemastore.org/tsconfig",
+ "extends": "@vitnode/config/tsconfig",
+ "compilerOptions": {
+ "target": "ESNext",
+ "module": "esnext",
+ "moduleResolution": "bundler",
+ "rootDir": "./",
+ "outDir": "./dist",
+ "incremental": false,
+ "emitDeclarationOnly": true,
+ "declaration": true,
+ "declarationMap": true,
+ "paths": {
+ "@/*": ["./src/*"]
+ }
+ },
+ "exclude": ["node_modules"],
+ "include": ["src", "vitest.config.ts"]
+}
diff --git a/packages/stripe/vitest.config.ts b/packages/stripe/vitest.config.ts
new file mode 100644
index 0000000000..28e3aea5c9
--- /dev/null
+++ b/packages/stripe/vitest.config.ts
@@ -0,0 +1,8 @@
+import { defineConfig } from "vitest/config";
+
+export default defineConfig({
+ test: {
+ environment: "node",
+ exclude: ["**/node_modules/**", "**/dist/**"],
+ },
+});
diff --git a/packages/vitnode/package.json b/packages/vitnode/package.json
index 597470d687..9d6f6b5cc2 100644
--- a/packages/vitnode/package.json
+++ b/packages/vitnode/package.json
@@ -140,6 +140,11 @@
"types": "./dist/src/content/hash.d.ts",
"default": "./dist/src/content/hash.js"
},
+ "./payments": {
+ "import": "./dist/src/payments/index.js",
+ "types": "./dist/src/payments/index.d.ts",
+ "default": "./dist/src/payments/index.js"
+ },
"./editor": {
"import": "./dist/src/editor/index.js",
"types": "./dist/src/editor/index.d.ts",
diff --git a/packages/vitnode/src/api/config.ts b/packages/vitnode/src/api/config.ts
index fff75efde2..73a57496f6 100644
--- a/packages/vitnode/src/api/config.ts
+++ b/packages/vitnode/src/api/config.ts
@@ -164,6 +164,7 @@ export function VitNodeAPI({
cron: vitNodeApiConfig.cron,
events: vitNodeApiConfig.events,
notifications: vitNodeApiConfig.notifications,
+ payments: vitNodeApiConfig.payments,
search: vitNodeApiConfig.search,
storage: vitNodeApiConfig.storage,
users: vitNodeApiConfig.users,
diff --git a/packages/vitnode/src/api/lib/module.ts b/packages/vitnode/src/api/lib/module.ts
index 53ea664442..5cc609f710 100644
--- a/packages/vitnode/src/api/lib/module.ts
+++ b/packages/vitnode/src/api/lib/module.ts
@@ -2,6 +2,7 @@ import { OpenAPIHono } from "@hono/zod-openapi";
import type { AnyContentModel } from "@/content/server/model";
import type { AnyContentTypeDefinition } from "@/content/types";
+import type { PaymentOffer } from "@/payments/offer";
import type { SearchIndexer } from "../models/search";
import type { BuildCronReturn } from "./cron";
@@ -30,6 +31,8 @@ export interface BaseBuildModuleReturn<
modules?: BaseBuildModuleReturn[];
name: M;
notificationTypes?: AnyNotificationTypeDefinition[];
+ /** Offers this module sells. Collected from every depth of the module tree. */
+ paymentOffers?: PaymentOffer[];
pluginId: P;
publicContentTypes?: AnyContentTypeDefinition[];
queueTasks: BuildQueueTaskReturn[];
@@ -60,6 +63,7 @@ export function buildModule<
contentModels,
contentTypes,
notificationTypes,
+ paymentOffers,
publicContentTypes,
cronJobs = [],
events = [],
@@ -74,6 +78,7 @@ export function buildModule<
modules?: Modules;
name: M;
notificationTypes?: AnyNotificationTypeDefinition[];
+ paymentOffers?: PaymentOffer[];
pluginId: P;
publicContentTypes?: AnyContentTypeDefinition[];
queueTasks?: BuildQueueTaskReturn[];
@@ -106,6 +111,7 @@ export function buildModule<
cronJobs,
events,
notificationTypes,
+ paymentOffers,
publicContentTypes,
queueTasks,
searchIndexers,
diff --git a/packages/vitnode/src/api/lib/plugin.test.ts b/packages/vitnode/src/api/lib/plugin.test.ts
index 379de31d82..7ef9f050bb 100644
--- a/packages/vitnode/src/api/lib/plugin.test.ts
+++ b/packages/vitnode/src/api/lib/plugin.test.ts
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import { createContentModel } from "@/content/server/model";
import { buildContentPublicModule } from "@/content/server/public-module";
+import { definePaymentOffer } from "@/payments/offer";
import {
testArticleContentType,
testCategoryContentType,
@@ -255,3 +256,59 @@ describe("buildApiPlugin public content URLs", () => {
).toEqual([]);
});
});
+
+describe("buildApiPlugin payment offers", () => {
+ const offer = definePaymentOffer({
+ id: "lifetime",
+ mode: "one_time",
+ name: "Lifetime",
+ onPaid: async () => {},
+ prices: { PLN: 1900 },
+ returnPath: "/example/payments",
+ });
+
+ it("collects offers from every depth of the module tree", () => {
+ const nested = buildModule({
+ pluginId: "@vitnode/example",
+ name: "admin",
+ routes: [],
+ modules: [
+ buildModule({
+ pluginId: "@vitnode/example",
+ name: "payments",
+ routes: [],
+ paymentOffers: [offer],
+ }),
+ ],
+ });
+
+ const plugin = buildApiPlugin({
+ pluginId: "@vitnode/example",
+ modules: [nested],
+ });
+
+ expect(plugin.paymentOffers?.map(item => item.id)).toEqual(["lifetime"]);
+ });
+
+ it("refuses one offer id declared by two modules of a plugin", () => {
+ expect(() =>
+ buildApiPlugin({
+ pluginId: "@vitnode/example",
+ modules: [
+ buildModule({
+ pluginId: "@vitnode/example",
+ name: "a",
+ routes: [],
+ paymentOffers: [offer],
+ }),
+ buildModule({
+ pluginId: "@vitnode/example",
+ name: "b",
+ routes: [],
+ paymentOffers: [offer],
+ }),
+ ],
+ }),
+ ).toThrow(/registered twice/);
+ });
+});
diff --git a/packages/vitnode/src/api/lib/plugin.ts b/packages/vitnode/src/api/lib/plugin.ts
index f5d4deecbb..f0316222b4 100644
--- a/packages/vitnode/src/api/lib/plugin.ts
+++ b/packages/vitnode/src/api/lib/plugin.ts
@@ -8,6 +8,7 @@ import type { AnyContentTypeDefinition } from "@/content/types";
import type { ApiPluginContract } from "@/lib/fetcher/contract";
import type { LocaleMessagesMap } from "@/lib/i18n/types";
import type { NavigationPresetDeclaration } from "@/lib/navigation";
+import type { PaymentOffer } from "@/payments/offer";
import { BlockError } from "@/blocks/errors";
import { contentPublicUrls } from "@/content/public-urls";
@@ -15,6 +16,7 @@ import {
validateContentTypes,
withContentPermissions,
} from "@/content/registry";
+import { validatePaymentOffers } from "@/payments/offer";
import type { SearchIndexer } from "../models/search";
import type { AnyAiActionDefinition } from "./ai/action";
@@ -55,6 +57,7 @@ export interface BuildPluginApiReturn<
navigation?: NavigationPresetDeclaration[];
notificationTypes?: AnyNotificationTypeDefinition[];
openApiTags?: string[];
+ paymentOffers?: PaymentOffer[];
permissionStaff?: PermissionStaffConfig;
pluginId: P;
publicContentTypes?: AnyContentTypeDefinition[];
@@ -125,6 +128,7 @@ export function buildApiPlugin<
...(notificationTypes ?? []),
];
const openApiTags: string[] = [];
+ const paymentOffers: PaymentOffer[] = [];
const queueTasks: BuildPluginApiReturn["queueTasks"] = [];
const webSockets: BuildPluginApiReturn["webSockets"] = [];
modules.forEach(handler => {
@@ -139,6 +143,9 @@ export function buildApiPlugin<
pluginNotificationTypes.push(
...collectModuleTree(handler, m => m.notificationTypes),
);
+ // Recursive on purpose: an offer declared on a nested module is a sale the
+ // plugin makes, and dropping it would surface as a 404 at checkout.
+ paymentOffers.push(...collectModuleTree(handler, m => m.paymentOffers));
handler.cronJobs?.forEach(cron => {
cronJobs.push({ ...cron, module: handler.name });
@@ -167,6 +174,8 @@ export function buildApiPlugin<
pluginNotificationTypes.map(definition => ({ definition, pluginId })),
);
+ validatePaymentOffers(paymentOffers.map(offer => ({ offer, pluginId })));
+
const publishing = new Map();
for (const definition of [
...registered.map(entry => entry.definition),
@@ -194,6 +203,7 @@ export function buildApiPlugin<
events,
queueTasks,
notificationTypes: pluginNotificationTypes,
+ paymentOffers,
searchIndexers: indexers,
webSockets,
// Every content type contributes can_view/can_create/can_edit/can_delete
diff --git a/packages/vitnode/src/api/lib/response-cache-headers.test.ts b/packages/vitnode/src/api/lib/response-cache-headers.test.ts
index 9af6d8c732..a35d9a6ab6 100644
--- a/packages/vitnode/src/api/lib/response-cache-headers.test.ts
+++ b/packages/vitnode/src/api/lib/response-cache-headers.test.ts
@@ -111,6 +111,7 @@ const isSharedCacheable = (value: string): boolean => {
};
const DECLARED = {
+ "api/modules/payments/private-response.ts": ["private, no-store"],
"content/server/delivery-routes.ts": ["private, no-store"],
"content/server/public-routes.ts": ["private, no-store"],
"tanstack/start/document-headers.ts": ["private, no-store"],
diff --git a/packages/vitnode/src/api/middlewares/global.middleware.ts b/packages/vitnode/src/api/middlewares/global.middleware.ts
index eb2c8805fc..21aa3d892e 100644
--- a/packages/vitnode/src/api/middlewares/global.middleware.ts
+++ b/packages/vitnode/src/api/middlewares/global.middleware.ts
@@ -9,6 +9,7 @@ import type { LocaleRouting } from "@/lib/i18n/locale-routing";
import type { LocaleConfig, MessagesSource } from "@/lib/i18n/types";
import type { NavigationPreset } from "@/lib/navigation";
import type { PersonalInformationFields } from "@/lib/user-personal-information";
+import type { PaymentsRegistry } from "@/payments/config";
import type { VitNodeApiConfig, VitNodeConfig } from "@/vitnode.config";
import type { VitNodeRealtime } from "@/ws/registry";
@@ -38,6 +39,7 @@ import { collectLocaleCodes } from "@/lib/i18n/load-messages";
import { localeRoutingFromConfig } from "@/lib/i18n/locale-routing";
import { buildApiMessagesSources } from "@/lib/i18n/sources";
import { resolvePersonalInformationFields } from "@/lib/user-personal-information";
+import { createPaymentsRegistry } from "@/payments/config";
import { realtime } from "@/ws/registry";
import type { AiActionRegistry } from "../lib/ai/registry";
@@ -161,6 +163,8 @@ export interface EnvVariablesVitNode {
navigation: NavigationPreset[];
notifications?: NotificationRegistry;
notificationWorkers?: NotificationWorkerSettings;
+ /** Resolved `payments` config (`null` when off) and every plugin's offers. */
+ payments: PaymentsRegistry;
permissionStaff: PermissionStaffCatalogEntry[];
/** Which personal-information fields this install offers. */
personalInformationFields: PersonalInformationFields;
@@ -221,6 +225,7 @@ export const globalMiddleware = ({
plugins,
i18n,
notifications,
+ payments,
search,
storage,
users,
@@ -237,6 +242,7 @@ export const globalMiddleware = ({
| "events"
| "i18n"
| "notifications"
+ | "payments"
| "plugins"
| "search"
| "storage"
@@ -399,6 +405,18 @@ export const globalMiddleware = ({
),
);
+ // At boot, so a malformed `payments` block or two plugins selling the same
+ // offer id stop the API before it takes a request rather than at checkout.
+ const paymentsMetadata = createPaymentsRegistry({
+ config: payments,
+ offers: plugins.flatMap(plugin =>
+ (plugin.paymentOffers ?? []).map(offer => ({
+ offer,
+ pluginId: plugin.pluginId,
+ })),
+ ),
+ });
+
const passkeysMetadata = resolvePasskeysConfig({
config: authorization?.passkeys,
rpNameFallback: metadata.shortTitle ?? metadata.title,
@@ -489,6 +507,7 @@ export const globalMiddleware = ({
navigation: navigationMetadata,
notifications: notificationsMetadata,
notificationWorkers,
+ payments: paymentsMetadata,
contentModels: contentModelsMetadata,
contentRevalidateOrigins: content?.revalidateOrigins,
contentTypes: contentTypesMetadata,
diff --git a/packages/vitnode/src/api/models/events.ts b/packages/vitnode/src/api/models/events.ts
index 6c75ebb158..11e129766a 100644
--- a/packages/vitnode/src/api/models/events.ts
+++ b/packages/vitnode/src/api/models/events.ts
@@ -67,6 +67,38 @@ export interface VitNodeEvents {
"notifications.type.updated": {
typeId: string;
};
+ "payments.purchase.created": {
+ offerId: string;
+ pluginId: string;
+ purchaseId: string;
+ userId: number;
+ };
+ "payments.purchase.fulfilled": {
+ offerId: string;
+ pluginId: string;
+ purchaseId: string;
+ };
+ "payments.purchase.paid": {
+ amount: number;
+ currency: string;
+ offerId: string;
+ pluginId: string;
+ purchaseId: string;
+ userId: null | number;
+ };
+ "payments.purchase.refunded": {
+ currency: string;
+ purchaseId: string;
+ refundedAmount: number;
+ refundStatus: "full" | "none" | "partial";
+ };
+ "payments.subscription.updated": {
+ offerId: string;
+ paidThrough: null | string;
+ pluginId: string;
+ status: string;
+ subscriptionId: string;
+ };
"role.created": {
roleId: number;
};
diff --git a/packages/vitnode/src/api/models/payments/checkout.integration.test.ts b/packages/vitnode/src/api/models/payments/checkout.integration.test.ts
new file mode 100644
index 0000000000..61c26ef006
--- /dev/null
+++ b/packages/vitnode/src/api/models/payments/checkout.integration.test.ts
@@ -0,0 +1,394 @@
+// @vitest-environment node
+import { eq } from "drizzle-orm";
+import { HTTPException } from "hono/http-exception";
+import { afterAll, beforeAll, describe, expect, it } from "vitest";
+
+import {
+ core_payments_checkouts,
+ core_payments_purchases,
+} from "@/database/payments";
+import { PaymentProviderError } from "@/payments/provider";
+import {
+ createPaymentsHarness,
+ must,
+ type PaymentsHarness,
+ TEST_PLUGIN_ID,
+} from "@/tests/payments";
+import { describePostgres } from "@/tests/postgres";
+
+import { cancelPurchase, startCheckout } from "./checkout";
+import { applyCheckoutState, refreshPurchase } from "./sync";
+
+const errorOf = async (promise: Promise) => {
+ try {
+ await promise;
+ } catch (error) {
+ if (error instanceof HTTPException) {
+ return {
+ status: error.status,
+ ...(await error.getResponse().json()),
+ } as {
+ code: string;
+ purchaseId?: string;
+ status: number;
+ };
+ }
+ throw error;
+ }
+ throw new Error("Expected the call to fail.");
+};
+
+const lifetime = (currency = "PLN", idempotencyKey?: string) => ({
+ currency,
+ idempotencyKey,
+ interval: null,
+ offerId: "lifetime",
+ pluginId: TEST_PLUGIN_ID,
+});
+
+describePostgres("checkout", () => {
+ let h: PaymentsHarness;
+
+ beforeAll(async () => {
+ h = await createPaymentsHarness();
+ });
+
+ afterAll(async () => {
+ await h?.database.drop();
+ });
+
+ it("derives the price on the server and snapshots the offer", async () => {
+ const user = await h.createUser();
+ const { checkoutUrl, purchase } = await startCheckout(
+ h.c,
+ user,
+ lifetime("USD"),
+ );
+
+ expect(checkoutUrl).toMatch(/^https:\/\/pay\.example\//);
+ expect(purchase).toMatchObject({
+ amount: 500,
+ currency: "USD",
+ offerName: "Lifetime supporter",
+ paymentStatus: "awaiting_payment",
+ userId: user.id,
+ });
+
+ const call = must(h.fake.createCalls.at(-1));
+ expect(call.input.item).toEqual({
+ amount: 500,
+ currency: "USD",
+ interval: undefined,
+ name: "Lifetime supporter",
+ });
+ expect(call.input.reference).toBe(purchase.publicId);
+ // Return URLs are built from the offer's path on the configured origin.
+ expect(new URL(call.input.successUrl).pathname).toBe("/example/payments");
+ expect(new URL(call.input.successUrl).searchParams.get("purchase")).toBe(
+ purchase.publicId,
+ );
+ });
+
+ it("refuses a currency the offer has no price in", async () => {
+ const user = await h.createUser();
+
+ expect(
+ await errorOf(startCheckout(h.c, user, lifetime("JPY"))),
+ ).toMatchObject({
+ code: "currency_unavailable",
+ status: 400,
+ });
+ });
+
+ it("refuses an interval on a one-time offer", async () => {
+ const user = await h.createUser();
+
+ expect(
+ await errorOf(
+ startCheckout(h.c, user, { ...lifetime(), interval: "month" }),
+ ),
+ ).toMatchObject({ code: "interval_unavailable", status: 400 });
+ });
+
+ it("returns the same checkout for a duplicate click", async () => {
+ const user = await h.createUser();
+ const before = h.fake.createCalls.length;
+
+ const first = await startCheckout(h.c, user, lifetime());
+ const second = await startCheckout(h.c, user, lifetime());
+
+ expect(second.purchase.id).toBe(first.purchase.id);
+ expect(second.checkoutUrl).toBe(first.checkoutUrl);
+ expect(h.fake.createCalls.length - before).toBe(1);
+ });
+
+ it("creates one purchase and one checkout for concurrent clicks", async () => {
+ const user = await h.createUser();
+ const before = h.fake.createCalls.length;
+ const contexts = Array.from({ length: 5 }, () => h.forkContext());
+
+ const results = await Promise.allSettled(
+ contexts.map(async c => await startCheckout(c, user, lifetime())),
+ );
+ const purchases = await h.database.db
+ .select()
+ .from(core_payments_purchases)
+ .where(eq(core_payments_purchases.userId, user.id));
+
+ expect(purchases).toHaveLength(1);
+ expect(
+ new Set(h.fake.createCalls.slice(before).map(call => call.idempotencyKey))
+ .size,
+ ).toBe(1);
+ for (const result of results) {
+ // Every click either got the one checkout or was told to retry.
+ if (result.status === "fulfilled") {
+ expect(result.value.purchase.id).toBe(purchases[0].id);
+ }
+ }
+ });
+
+ it("refuses an idempotency key reused for a different request", async () => {
+ const user = await h.createUser();
+ await startCheckout(h.c, user, lifetime("PLN", "key-aaaaaaaa"));
+
+ expect(
+ await errorOf(startCheckout(h.c, user, lifetime("USD", "key-aaaaaaaa"))),
+ ).toMatchObject({ code: "idempotency_conflict", status: 409 });
+ });
+
+ it("scopes idempotency keys to the buyer", async () => {
+ const alice = await h.createUser();
+ const bob = await h.createUser();
+
+ const a = await startCheckout(h.c, alice, lifetime("PLN", "shared-key-1"));
+ const b = await startCheckout(h.c, bob, lifetime("USD", "shared-key-1"));
+
+ expect(b.purchase.id).not.toBe(a.purchase.id);
+ expect(b.purchase.userId).toBe(bob.id);
+ });
+
+ it("recovers from a timeout without creating a second checkout", async () => {
+ const user = await h.createUser();
+ h.fake.failNext(
+ "create:lost",
+ new PaymentProviderError("uncertain", "Socket hang up"),
+ );
+
+ expect(
+ await errorOf(startCheckout(h.c, user, lifetime("PLN", "retry-key-1"))),
+ ).toMatchObject({ code: "checkout_uncertain", status: 503 });
+
+ const [unknown] = await h.database.db
+ .select()
+ .from(core_payments_checkouts)
+ .innerJoin(
+ core_payments_purchases,
+ eq(core_payments_purchases.id, core_payments_checkouts.purchaseId),
+ )
+ .where(eq(core_payments_purchases.userId, user.id));
+ expect(unknown.core_payments_checkouts.status).toBe("unknown");
+
+ const retried = await startCheckout(
+ h.c,
+ user,
+ lifetime("PLN", "retry-key-1"),
+ );
+ const calls = h.fake.createCalls.filter(
+ call => call.input.reference === retried.purchase.publicId,
+ );
+
+ expect(retried.checkoutUrl).toMatch(/pay\.example/);
+ expect(calls).toHaveLength(2);
+ expect(calls[0].idempotencyKey).toBe(calls[1].idempotencyKey);
+ expect(
+ [...h.fake.checkouts.values()].filter(
+ checkout => checkout.reference === retried.purchase.publicId,
+ ),
+ ).toHaveLength(1);
+ });
+
+ it("marks a refused checkout failed and lets the next click try again", async () => {
+ const user = await h.createUser();
+ h.fake.failNext(
+ "create",
+ new PaymentProviderError("rejected", "Invalid currency"),
+ );
+
+ expect(await errorOf(startCheckout(h.c, user, lifetime()))).toMatchObject({
+ code: "checkout_failed",
+ status: 502,
+ });
+
+ const retried = await startCheckout(h.c, user, lifetime());
+ const attempts = await h.database.db
+ .select({ status: core_payments_checkouts.status })
+ .from(core_payments_checkouts)
+ .where(eq(core_payments_checkouts.purchaseId, retried.purchase.id));
+
+ expect(attempts.map(attempt => attempt.status).sort()).toEqual([
+ "failed",
+ "open",
+ ]);
+ });
+
+ it("starts a new purchase once the previous checkout expired", async () => {
+ const user = await h.createUser();
+ const first = await startCheckout(h.c, user, lifetime());
+ const checkoutId = must(h.fake.createCalls.at(-1));
+ const [stored] = await h.database.db
+ .select()
+ .from(core_payments_checkouts)
+ .where(eq(core_payments_checkouts.purchaseId, first.purchase.id));
+
+ h.fake.expireCheckout(must(stored.externalId));
+ await h.database.db
+ .update(core_payments_checkouts)
+ .set({ expiresAt: new Date(Date.now() - 1000) })
+ .where(eq(core_payments_checkouts.id, stored.id));
+
+ const second = await startCheckout(h.c, user, lifetime());
+ const [old] = await h.database.db
+ .select()
+ .from(core_payments_purchases)
+ .where(eq(core_payments_purchases.id, first.purchase.id));
+
+ expect(checkoutId.input.reference).toBe(first.purchase.publicId);
+ expect(old.paymentStatus).toBe("expired");
+ expect(second.purchase.id).not.toBe(first.purchase.id);
+ expect(second.checkoutUrl).toMatch(/pay\.example/);
+ });
+
+ it("refuses a second checkout in another currency while one is open", async () => {
+ const user = await h.createUser();
+ const open = await startCheckout(h.c, user, lifetime("PLN"));
+
+ expect(
+ await errorOf(startCheckout(h.c, user, lifetime("USD"))),
+ ).toMatchObject({
+ code: "checkout_in_progress",
+ purchaseId: open.purchase.publicId,
+ status: 409,
+ });
+
+ await cancelPurchase(h.c, open.purchase);
+ const switched = await startCheckout(h.c, user, lifetime("USD"));
+
+ expect(switched.purchase.currency).toBe("USD");
+ });
+
+ it("lets a payment that landed win over a cancel", async () => {
+ const user = await h.createUser();
+ const { purchase } = await startCheckout(h.c, user, lifetime());
+ const checkoutId = must(
+ [...h.fake.checkouts.values()].find(
+ checkout => checkout.reference === purchase.publicId,
+ ),
+ ).id;
+ h.fake.complete(checkoutId);
+
+ const result = await cancelPurchase(h.c, purchase);
+
+ expect(result.paymentStatus).toBe("paid");
+ });
+
+ it("refuses an ineligible buyer on the server", async () => {
+ const user = await h.createUser();
+ const { purchase } = await startCheckout(h.c, user, lifetime());
+ const checkout = must(
+ [...h.fake.checkouts.values()].find(
+ item => item.reference === purchase.publicId,
+ ),
+ );
+ h.fake.complete(checkout.id);
+ await refreshPurchase(h.c, h.fake.provider, purchase);
+ await h.drainQueue();
+
+ expect(await errorOf(startCheckout(h.c, user, lifetime()))).toMatchObject({
+ code: "not_eligible",
+ status: 409,
+ });
+ });
+
+ it("closes a checkout about to expire before opening the next one", async () => {
+ const user = await h.createUser();
+ const first = await startCheckout(h.c, user, lifetime());
+ const [old] = await h.database.db
+ .select()
+ .from(core_payments_checkouts)
+ .where(eq(core_payments_checkouts.purchaseId, first.purchase.id));
+ await h.database.db
+ .update(core_payments_checkouts)
+ .set({ expiresAt: new Date(Date.now() + 60_000) })
+ .where(eq(core_payments_checkouts.id, old.id));
+
+ const second = await startCheckout(h.c, user, lifetime());
+ const oldId = must(old.externalId);
+
+ expect(second.purchase.id).toBe(first.purchase.id);
+ expect(second.checkoutUrl).not.toBe(first.checkoutUrl);
+ expect(must(h.fake.checkouts.get(oldId)).status).toBe("expired");
+
+ // The old session's "expired" webhook lands after the new one opened.
+ const applied = await applyCheckoutState(
+ h.c,
+ h.fake.provider,
+ await h.fake.provider.checkout.retrieve(oldId),
+ );
+
+ expect(applied.purchase.paymentStatus).toBe("awaiting_payment");
+ expect(await startCheckout(h.c, user, lifetime())).toMatchObject({
+ checkoutUrl: second.checkoutUrl,
+ });
+ });
+
+ it("reuses one billing customer per user", async () => {
+ const user = await h.createUser();
+ await startCheckout(h.c, user, lifetime("PLN"));
+ await startCheckout(h.c, user, {
+ ...lifetime("PLN"),
+ interval: "month",
+ offerId: "plan",
+ });
+
+ expect(
+ h.fake.customerCalls.filter(call => call.userId === user.id),
+ ).toHaveLength(1);
+ });
+});
+
+describePostgres("checkout with Payments disabled", () => {
+ let h: PaymentsHarness;
+
+ beforeAll(async () => {
+ h = await createPaymentsHarness({ payments: false });
+ });
+
+ afterAll(async () => {
+ await h?.database.drop();
+ });
+
+ it("answers that Payments is disabled", async () => {
+ const user = await h.createUser();
+
+ expect(await errorOf(startCheckout(h.c, user, lifetime()))).toMatchObject({
+ code: "payments_disabled",
+ status: 503,
+ });
+ });
+});
+
+describe("startCheckout input", () => {
+ it("has no field for a price, a buyer or a URL", () => {
+ // The type is the contract: anything authoritative is resolved server-side.
+ const request: Parameters[2] = lifetime();
+
+ expect(Object.keys(request).sort()).toEqual([
+ "currency",
+ "idempotencyKey",
+ "interval",
+ "offerId",
+ "pluginId",
+ ]);
+ });
+});
diff --git a/packages/vitnode/src/api/models/payments/checkout.ts b/packages/vitnode/src/api/models/payments/checkout.ts
new file mode 100644
index 0000000000..96d030dd85
--- /dev/null
+++ b/packages/vitnode/src/api/models/payments/checkout.ts
@@ -0,0 +1,786 @@
+import { and, desc, eq, inArray, notInArray, sql } from "drizzle-orm";
+import { createHash, randomUUID } from "node:crypto";
+
+import type { PaymentOffer } from "@/payments/offer";
+import type { PaymentProvider, ProviderCheckout } from "@/payments/provider";
+import type { BillingInterval } from "@/payments/status";
+
+import {
+ core_payments_checkouts,
+ core_payments_customers,
+ core_payments_purchases,
+ core_payments_subscriptions,
+} from "@/database/payments";
+import { CONFIG } from "@/lib/config";
+import { findPaymentProvider, priceUnavailableReason } from "@/payments/config";
+import { assertMinorUnits } from "@/payments/money";
+import { offerPricesFor } from "@/payments/offer";
+import { isPaymentProviderError } from "@/payments/provider";
+import { TERMINAL_SUBSCRIPTION_STATUSES } from "@/payments/status";
+
+import type { PaymentsContext, PurchaseRow } from "./shared";
+
+import {
+ describePaymentsError,
+ emitPaymentsEvent,
+ findRegisteredOffer,
+ hashCheckoutRequest,
+ paymentsHttpError,
+ PaymentsPermanentError,
+ providerForRow,
+ requirePaymentsConfig,
+ webUrl,
+} from "./shared";
+import { applyCheckoutState, refreshPurchase } from "./sync";
+
+type CheckoutRow = typeof core_payments_checkouts.$inferSelect;
+
+export interface CheckoutRequest {
+ currency: string;
+ /** Client-generated, scoped to the buyer. Same key + same request = same result. */
+ idempotencyKey?: string;
+ interval: BillingInterval | null;
+ offerId: string;
+ pluginId: string;
+ /** Defaults to `payments.defaultProvider`. */
+ provider?: string;
+}
+
+export interface CheckoutResult {
+ checkoutUrl: null | string;
+ purchase: PurchaseRow;
+}
+
+interface CheckoutUser {
+ email: string;
+ id: number;
+ language: string;
+ name: string;
+}
+
+/**
+ * How long an attempt whose create call ended without an answer may still be
+ * replayed with the same idempotency key. The replay sends the attempt's
+ * original expiry, and Stripe refuses one less than 30 minutes away - so the
+ * window is what is left of the checkout lifetime after those 30 minutes and a
+ * margin, and never more than 20 minutes.
+ */
+const replayWindowMs = (checkoutExpiresInMinutes: number): number =>
+ Math.max(0, Math.min(20, checkoutExpiresInMinutes - 35)) * 60_000;
+
+/** An open checkout this close to expiring is not handed out again. */
+const OPEN_CHECKOUT_MARGIN_MS = 2 * 60_000;
+
+const resolveOfferName = async (
+ c: PaymentsContext,
+ offer: PaymentOffer,
+ language: string,
+): Promise => {
+ if (!offer.nameKey) return offer.name;
+
+ try {
+ const t = await c.get("i18n").getTranslator(language);
+ // The key is a full path, so it is looked up from the root.
+ const translated = t.has(offer.nameKey as never)
+ ? t(offer.nameKey as never)
+ : null;
+
+ return typeof translated === "string" && translated.trim() !== ""
+ ? translated.slice(0, 255)
+ : offer.name;
+ } catch {
+ return offer.name;
+ }
+};
+
+/**
+ * The provider customer for a user, created once per provider and environment.
+ * Concurrent first purchases converge on one customer: the provider call uses
+ * an idempotency key derived from the user, and the insert loses gracefully.
+ */
+export const ensureBillingCustomer = async (
+ c: PaymentsContext,
+ provider: PaymentProvider,
+ user: CheckoutUser,
+): Promise => {
+ const db = c.get("db");
+ const where = and(
+ eq(core_payments_customers.provider, provider.id),
+ eq(core_payments_customers.providerScope, provider.scope),
+ eq(core_payments_customers.userId, user.id),
+ );
+ const [existing] = await db
+ .select({ externalId: core_payments_customers.externalId })
+ .from(core_payments_customers)
+ .where(where);
+
+ if (existing) return existing.externalId;
+
+ const { customerId } = await provider.customers.create(
+ { email: user.email, name: user.name, userId: user.id },
+ {
+ // Per site, not just per user id: two installs sharing one Stripe account
+ // must never be handed each other's customer by an idempotent replay.
+ idempotencyKey: `vitnode-customer-${createHash("sha256")
+ .update(CONFIG.web.origin)
+ .digest("hex")
+ .slice(0, 16)}-${provider.scope}-${user.id}`,
+ },
+ );
+
+ await db
+ .insert(core_payments_customers)
+ .values({
+ externalId: customerId,
+ provider: provider.id,
+ providerScope: provider.scope,
+ userId: user.id,
+ })
+ .onConflictDoNothing();
+
+ const [stored] = await db
+ .select({ externalId: core_payments_customers.externalId })
+ .from(core_payments_customers)
+ .where(where);
+
+ if (!stored) {
+ throw new PaymentsPermanentError(
+ `Provider customer ${customerId} is already linked to another account on this site.`,
+ );
+ }
+
+ return stored.externalId;
+};
+
+const isUsableOpenCheckout = (checkout: CheckoutRow | undefined) =>
+ checkout?.status === "open" &&
+ !!checkout.url &&
+ checkout.expiresAt.getTime() - Date.now() > OPEN_CHECKOUT_MARGIN_MS;
+
+const latestCheckout = async (
+ c: PaymentsContext,
+ purchaseId: number,
+): Promise => {
+ const [row] = await c
+ .get("db")
+ .select()
+ .from(core_payments_checkouts)
+ .where(eq(core_payments_checkouts.purchaseId, purchaseId))
+ .orderBy(desc(core_payments_checkouts.id))
+ .limit(1);
+
+ return row;
+};
+
+/**
+ * An open purchase whose checkout should already have expired is asked about
+ * before a new checkout is refused because of it - an abandoned tab must not
+ * lock the buyer out until reconciliation runs.
+ */
+const settleStaleOpenPurchase = async (
+ c: PaymentsContext,
+ userId: number,
+ pluginId: string,
+ offerId: string,
+): Promise => {
+ const [open] = await c
+ .get("db")
+ .select()
+ .from(core_payments_purchases)
+ .where(
+ and(
+ eq(core_payments_purchases.userId, userId),
+ eq(core_payments_purchases.pluginId, pluginId),
+ eq(core_payments_purchases.offerId, offerId),
+ eq(core_payments_purchases.paymentStatus, "awaiting_payment"),
+ ),
+ );
+
+ if (!open) return;
+
+ const checkout = await latestCheckout(c, open.id);
+ if (!checkout || checkout.expiresAt.getTime() > Date.now()) return;
+
+ try {
+ const provider = providerForRow(c, open);
+
+ if (checkout.externalId) {
+ await refreshPurchase(c, provider, open);
+
+ return;
+ }
+ } catch (error) {
+ await c
+ .get("log")
+ .warn(
+ `[Payments] Could not refresh purchase ${open.publicId}: ${describePaymentsError(error)}`,
+ );
+
+ return;
+ }
+
+ // No provider checkout was ever confirmed and the window has passed.
+ await c
+ .get("db")
+ .update(core_payments_purchases)
+ .set({ nextCheckAt: null, paymentStatus: "expired" })
+ .where(
+ and(
+ eq(core_payments_purchases.id, open.id),
+ eq(core_payments_purchases.paymentStatus, "awaiting_payment"),
+ ),
+ );
+};
+
+/**
+ * Starts (or resumes) a hosted checkout for the signed-in buyer.
+ *
+ * The browser names only an offer, a currency, an interval and optionally a
+ * provider. The price, the buyer, the customer, the return URLs and everything
+ * stored come from the server. The purchase and its checkout attempt are
+ * written - under a per-buyer-and-offer lock - before the provider is called,
+ * and the provider is called outside any transaction with an idempotency key
+ * stored on the attempt, so a timeout followed by a retry gets the same
+ * checkout back instead of a second one.
+ */
+export const startCheckout = async (
+ c: PaymentsContext,
+ user: CheckoutUser,
+ request: CheckoutRequest,
+): Promise => {
+ const config = requirePaymentsConfig(c);
+ const registered = findRegisteredOffer(c, request.pluginId, request.offerId);
+
+ if (!registered) {
+ throw paymentsHttpError(
+ 404,
+ "offer_not_found",
+ "This offer does not exist.",
+ );
+ }
+
+ const { offer, pluginId } = registered;
+ const provider = findPaymentProvider(config, request.provider);
+
+ if (!provider) {
+ throw paymentsHttpError(
+ 400,
+ "provider_unavailable",
+ "That payment provider is not available.",
+ );
+ }
+
+ const prices = offerPricesFor(offer, request.interval);
+
+ if (!prices) {
+ throw paymentsHttpError(
+ 400,
+ "interval_unavailable",
+ offer.mode === "subscription"
+ ? "Choose a billing interval this plan offers."
+ : "A one-time offer has no billing interval.",
+ );
+ }
+
+ const amount = prices[request.currency];
+
+ if (amount === undefined) {
+ throw paymentsHttpError(
+ 400,
+ "currency_unavailable",
+ `This offer has no price in ${request.currency}.`,
+ );
+ }
+
+ const money = {
+ amount: assertMinorUnits(amount, { positive: true }),
+ currency: request.currency,
+ };
+ const unavailable = priceUnavailableReason(config, provider, offer, money);
+
+ if (unavailable) {
+ throw paymentsHttpError(400, "currency_unavailable", unavailable);
+ }
+
+ const requestHash = hashCheckoutRequest([
+ pluginId,
+ offer.id,
+ money.currency,
+ request.interval,
+ provider.id,
+ provider.scope,
+ ]);
+
+ await settleStaleOpenPurchase(c, user.id, pluginId, offer.id);
+
+ const offerName = await resolveOfferName(c, offer, user.language);
+ let created = false;
+
+ const decision = await c.get("db").transaction(async tx => {
+ // One checkout decision at a time per buyer and offer: the eligibility
+ // read and the insert below cannot interleave with another click.
+ await tx.execute(
+ sql`SELECT pg_advisory_xact_lock(hashtext(${`payments:${user.id}:${pluginId}:${offer.id}`}))`,
+ );
+
+ if (request.idempotencyKey) {
+ const [previous] = await tx
+ .select()
+ .from(core_payments_purchases)
+ .where(
+ and(
+ eq(core_payments_purchases.userId, user.id),
+ eq(core_payments_purchases.idempotencyKey, request.idempotencyKey),
+ ),
+ );
+
+ if (previous) {
+ if (previous.requestHash !== requestHash) {
+ throw paymentsHttpError(
+ 409,
+ "idempotency_conflict",
+ "This idempotency key was already used for a different checkout.",
+ );
+ }
+
+ return previous;
+ }
+ }
+
+ const [open] = await tx
+ .select()
+ .from(core_payments_purchases)
+ .where(
+ and(
+ eq(core_payments_purchases.userId, user.id),
+ eq(core_payments_purchases.pluginId, pluginId),
+ eq(core_payments_purchases.offerId, offer.id),
+ inArray(core_payments_purchases.paymentStatus, [
+ "awaiting_payment",
+ "processing",
+ ]),
+ ),
+ );
+
+ if (open) {
+ if (open.paymentStatus === "processing") {
+ throw paymentsHttpError(
+ 409,
+ "payment_processing",
+ "A payment for this offer is still being processed.",
+ { purchaseId: open.publicId },
+ );
+ }
+
+ // The same request again - a double click or a second tab.
+ if (open.requestHash === requestHash) return open;
+
+ throw paymentsHttpError(
+ 409,
+ "checkout_in_progress",
+ "You already have an unfinished checkout for this offer. Continue it or cancel it first.",
+ { purchaseId: open.publicId },
+ );
+ }
+
+ if (offer.mode === "subscription") {
+ const [live] = await tx
+ .select({ id: core_payments_subscriptions.id })
+ .from(core_payments_subscriptions)
+ .where(
+ and(
+ eq(core_payments_subscriptions.userId, user.id),
+ eq(core_payments_subscriptions.pluginId, pluginId),
+ eq(core_payments_subscriptions.offerId, offer.id),
+ notInArray(core_payments_subscriptions.status, [
+ ...TERMINAL_SUBSCRIPTION_STATUSES,
+ ]),
+ ),
+ )
+ .limit(1);
+
+ if (live) {
+ throw paymentsHttpError(
+ 409,
+ "already_subscribed",
+ "You already have a subscription to this plan. Manage it from your billing settings.",
+ );
+ }
+ }
+
+ const eligibility = (await offer.isEligible?.({
+ c,
+ interval: request.interval,
+ tx,
+ userId: user.id,
+ })) ?? { eligible: true };
+
+ if (!eligibility.eligible) {
+ throw paymentsHttpError(409, "not_eligible", eligibility.reason);
+ }
+
+ const expiresAt = new Date(
+ Date.now() + config.checkoutExpiresInMinutes * 60_000,
+ );
+ const [purchase] = await tx
+ .insert(core_payments_purchases)
+ .values({
+ amount: money.amount,
+ currency: money.currency,
+ idempotencyKey: request.idempotencyKey ?? null,
+ interval: request.interval,
+ mode: offer.mode,
+ // Checked again after the checkout window, in case nothing reports back.
+ nextCheckAt: new Date(expiresAt.getTime() + 5 * 60_000),
+ offerId: offer.id,
+ offerName,
+ pluginId,
+ provider: provider.id,
+ providerScope: provider.scope,
+ requestHash,
+ userId: user.id,
+ })
+ .returning();
+
+ created = true;
+
+ return purchase;
+ });
+
+ if (created) {
+ await emitPaymentsEvent(c, "payments.purchase.created", {
+ offerId: decision.offerId,
+ pluginId: decision.pluginId,
+ purchaseId: decision.publicId,
+ userId: user.id,
+ });
+ }
+
+ return await openCheckout(c, provider, offer, user, decision);
+};
+
+/** Picks the attempt to (re)use, under a row lock so concurrent retries agree. */
+const claimAttempt = async (
+ c: PaymentsContext,
+ provider: PaymentProvider,
+ purchase: PurchaseRow,
+): Promise<
+ | { attempt: CheckoutRow; reuse: boolean }
+ | { supersede: CheckoutRow }
+ | { url: string }
+> => {
+ const config = requirePaymentsConfig(c);
+
+ return await c.get("db").transaction(async tx => {
+ await tx
+ .select({ id: core_payments_purchases.id })
+ .from(core_payments_purchases)
+ .where(eq(core_payments_purchases.id, purchase.id))
+ .for("update");
+
+ const [latest] = await tx
+ .select()
+ .from(core_payments_checkouts)
+ .where(eq(core_payments_checkouts.purchaseId, purchase.id))
+ .orderBy(desc(core_payments_checkouts.id))
+ .limit(1);
+
+ if (latest && isUsableOpenCheckout(latest) && latest.url) {
+ return { url: latest.url };
+ }
+
+ // About to expire but still payable: it is closed at the provider before
+ // a new one exists, so the buyer can never pay both.
+ if (latest?.status === "open" && latest.externalId) {
+ return { supersede: latest };
+ }
+
+ if (
+ latest &&
+ (latest.status === "creating" || latest.status === "unknown") &&
+ Date.now() - latest.createdAt.getTime() <
+ replayWindowMs(config.checkoutExpiresInMinutes)
+ ) {
+ return { attempt: latest, reuse: true };
+ }
+
+ if (
+ latest &&
+ (latest.status === "creating" || latest.status === "unknown")
+ ) {
+ await tx
+ .update(core_payments_checkouts)
+ .set({
+ lastError: "Abandoned: the provider never confirmed this checkout.",
+ status: "failed",
+ })
+ .where(eq(core_payments_checkouts.id, latest.id));
+ }
+
+ const [attempt] = await tx
+ .insert(core_payments_checkouts)
+ .values({
+ expiresAt: new Date(
+ Date.now() + config.checkoutExpiresInMinutes * 60_000,
+ ),
+ idempotencyKey: `vitnode-checkout-${randomUUID()}`,
+ provider: provider.id,
+ providerScope: provider.scope,
+ purchaseId: purchase.id,
+ })
+ .returning();
+
+ return { attempt, reuse: false };
+ });
+};
+
+const openCheckout = async (
+ c: PaymentsContext,
+ provider: PaymentProvider,
+ offer: PaymentOffer,
+ user: CheckoutUser,
+ purchase: PurchaseRow,
+): Promise => {
+ if (purchase.paymentStatus !== "awaiting_payment") {
+ return { checkoutUrl: null, purchase };
+ }
+
+ const db = c.get("db");
+ let claimed = await claimAttempt(c, provider, purchase);
+ if ("url" in claimed) return { checkoutUrl: claimed.url, purchase };
+
+ if ("supersede" in claimed) {
+ const previous = claimed.supersede;
+ let state: ProviderCheckout;
+
+ try {
+ state = await provider.checkout.expire(previous.externalId ?? "");
+ } catch (error) {
+ if (isPaymentProviderError(error) && error.kind === "rejected") {
+ // Usually "already complete" - whatever happened there wins.
+ return {
+ checkoutUrl: null,
+ purchase: await refreshPurchase(c, provider, purchase),
+ };
+ }
+
+ throw error;
+ }
+
+ if (state.status === "complete") {
+ const applied = await applyCheckoutState(c, provider, state);
+
+ return { checkoutUrl: null, purchase: applied.purchase };
+ }
+
+ await db
+ .update(core_payments_checkouts)
+ .set({ status: "expired", url: null })
+ .where(eq(core_payments_checkouts.id, previous.id));
+
+ claimed = await claimAttempt(c, provider, purchase);
+ if ("url" in claimed) return { checkoutUrl: claimed.url, purchase };
+ if ("supersede" in claimed) {
+ throw paymentsHttpError(
+ 409,
+ "checkout_in_progress",
+ "Another checkout for this purchase is being prepared. Try again in a moment.",
+ { purchaseId: purchase.publicId },
+ );
+ }
+ }
+
+ const { attempt } = claimed;
+ const returnParams = { purchase: purchase.publicId };
+ let checkout: ProviderCheckout;
+
+ try {
+ const customerId = await ensureBillingCustomer(c, provider, user);
+
+ checkout = await provider.checkout.create(
+ {
+ cancelUrl: webUrl(offer.returnPath, {
+ ...returnParams,
+ checkout: "canceled",
+ }),
+ customerId,
+ expiresAt: attempt.expiresAt,
+ item: {
+ amount: purchase.amount,
+ currency: purchase.currency,
+ interval: purchase.interval ?? undefined,
+ name: purchase.offerName,
+ },
+ locale: user.language,
+ metadata: {
+ vitnode_offer: `${purchase.pluginId}:${purchase.offerId}`,
+ vitnode_user_id: String(user.id),
+ },
+ mode: purchase.mode,
+ reference: purchase.publicId,
+ successUrl: webUrl(offer.returnPath, returnParams),
+ },
+ { idempotencyKey: attempt.idempotencyKey },
+ );
+ } catch (error) {
+ const message = describePaymentsError(error);
+ const uncertain = !isPaymentProviderError(error) || error.retryable;
+
+ await db
+ .update(core_payments_checkouts)
+ .set({ lastError: message, status: uncertain ? "unknown" : "failed" })
+ .where(
+ and(
+ eq(core_payments_checkouts.id, attempt.id),
+ inArray(core_payments_checkouts.status, ["creating", "unknown"]),
+ ),
+ );
+
+ await c
+ .get("log")
+ .warn(`[Payments] Checkout for ${purchase.publicId} failed: ${message}`);
+
+ if (uncertain) {
+ throw paymentsHttpError(
+ 503,
+ "checkout_uncertain",
+ "The payment provider did not answer. Try again - you will not be charged twice.",
+ { purchaseId: purchase.publicId },
+ );
+ }
+
+ throw paymentsHttpError(
+ 502,
+ "checkout_failed",
+ "The payment provider could not start this checkout.",
+ { purchaseId: purchase.publicId },
+ );
+ }
+
+ await db
+ .update(core_payments_checkouts)
+ .set({
+ expiresAt: checkout.expiresAt ?? attempt.expiresAt,
+ externalId: checkout.id,
+ lastError: null,
+ status: checkout.status,
+ url: checkout.url,
+ })
+ .where(eq(core_payments_checkouts.id, attempt.id));
+
+ await db
+ .update(core_payments_purchases)
+ .set({ externalCustomerId: checkout.customerId })
+ .where(eq(core_payments_purchases.id, purchase.id));
+
+ if (checkout.status !== "open") {
+ // A replayed create can return a checkout that already finished.
+ const applied = await applyCheckoutState(c, provider, checkout);
+
+ return { checkoutUrl: null, purchase: applied.purchase };
+ }
+
+ return { checkoutUrl: checkout.url, purchase };
+};
+
+/**
+ * Cancels the buyer's own unfinished purchase so they can start over (for
+ * example in another currency). The provider checkout is expired first; if it
+ * turns out to have been paid in the meantime, that wins.
+ */
+export const cancelPurchase = async (
+ c: PaymentsContext,
+ purchase: PurchaseRow,
+): Promise => {
+ if (purchase.paymentStatus !== "awaiting_payment") {
+ throw paymentsHttpError(
+ 409,
+ "not_cancelable",
+ "Only a checkout that has not been paid can be canceled.",
+ );
+ }
+
+ const provider = providerForRow(c, purchase);
+ const checkout = await latestCheckout(c, purchase.id);
+
+ if (checkout?.externalId && checkout.status === "open") {
+ try {
+ const state = await provider.checkout.expire(checkout.externalId);
+ const applied = await applyCheckoutState(c, provider, state);
+
+ if (applied.purchase.paymentStatus !== "expired") return applied.purchase;
+ } catch (error) {
+ if (isPaymentProviderError(error) && error.kind === "rejected") {
+ // Usually "already complete": read what really happened.
+ return await refreshPurchase(c, provider, purchase);
+ }
+
+ throw error;
+ }
+ }
+
+ const [canceled] = await c
+ .get("db")
+ .update(core_payments_purchases)
+ .set({ nextCheckAt: null, paymentStatus: "canceled" })
+ .where(
+ and(
+ eq(core_payments_purchases.id, purchase.id),
+ inArray(core_payments_purchases.paymentStatus, [
+ "awaiting_payment",
+ "expired",
+ ]),
+ ),
+ )
+ .returning();
+
+ return canceled ?? purchase;
+};
+
+/**
+ * A Customer Portal session for the signed-in user's own billing account. The
+ * customer comes from the stored mapping, never from the request.
+ */
+export const createPortalSession = async (
+ c: PaymentsContext,
+ userId: number,
+ { providerId, returnPath }: { providerId?: string; returnPath: string },
+): Promise => {
+ const config = requirePaymentsConfig(c);
+ const provider = findPaymentProvider(config, providerId);
+
+ if (!provider?.portal) {
+ throw paymentsHttpError(
+ 400,
+ "portal_unavailable",
+ "Billing management is not available for this provider.",
+ );
+ }
+
+ const [customer] = await c
+ .get("db")
+ .select({ externalId: core_payments_customers.externalId })
+ .from(core_payments_customers)
+ .where(
+ and(
+ eq(core_payments_customers.provider, provider.id),
+ eq(core_payments_customers.providerScope, provider.scope),
+ eq(core_payments_customers.userId, userId),
+ ),
+ );
+
+ if (!customer) {
+ throw paymentsHttpError(
+ 404,
+ "no_billing_account",
+ "You have no billing account yet. It is created with your first purchase.",
+ );
+ }
+
+ const { url } = await provider.portal.createSession({
+ customerId: customer.externalId,
+ returnUrl: webUrl(returnPath),
+ });
+
+ return url;
+};
diff --git a/packages/vitnode/src/api/models/payments/fulfillment.ts b/packages/vitnode/src/api/models/payments/fulfillment.ts
new file mode 100644
index 0000000000..c8340bc18f
--- /dev/null
+++ b/packages/vitnode/src/api/models/payments/fulfillment.ts
@@ -0,0 +1,351 @@
+import { and, eq, sql } from "drizzle-orm";
+
+import type { PaymentsTransaction } from "@/payments/offer";
+
+import {
+ core_payments_fulfillments,
+ core_payments_purchases,
+ core_payments_subscriptions,
+} from "@/database/payments";
+
+import type { PaymentsContext } from "./shared";
+
+import {
+ describePaymentsError,
+ emitPaymentsEvent,
+ findRegisteredOffer,
+ PaymentsPermanentError,
+ purchaseSnapshot,
+ subscriptionSnapshot,
+} from "./shared";
+
+export const FULFILL_TASK = "payments-fulfill";
+
+/** The plugin effects a purchase can have. Refunds are one per refund id. */
+export const FULFILLMENT_EFFECTS = {
+ grant: "grant",
+ refund: (refundId: string) => `refund:${refundId}`,
+ subscription: "subscription",
+} as const;
+
+const affectsPurchaseStatus = (effect: string): boolean =>
+ effect === FULFILLMENT_EFFECTS.grant ||
+ effect === FULFILLMENT_EFFECTS.subscription;
+
+/**
+ * Records that a business effect is owed and queues it - in the caller's
+ * transaction, so the payment state and the work it causes commit together.
+ *
+ * The row is unique per purchase and effect: asking twice for the same effect
+ * finds the existing row. `rerun` is for state-based effects (a subscription
+ * changed again) - it moves the generation on so the handler runs once more
+ * with the newest state, still without granting anything twice.
+ */
+export const scheduleFulfillment = async (
+ c: PaymentsContext,
+ tx: PaymentsTransaction,
+ {
+ effect,
+ payload = {},
+ purchaseId,
+ rerun = false,
+ }: {
+ effect: string;
+ payload?: Record;
+ purchaseId: number;
+ rerun?: boolean;
+ },
+): Promise => {
+ const [inserted] = await tx
+ .insert(core_payments_fulfillments)
+ .values({ effect, payload, purchaseId })
+ .onConflictDoNothing()
+ .returning({ id: core_payments_fulfillments.id });
+
+ let id = inserted?.id;
+
+ if (!id && rerun) {
+ const [bumped] = await tx
+ .update(core_payments_fulfillments)
+ .set({
+ generation: sql`${core_payments_fulfillments.generation} + 1`,
+ status: "pending",
+ })
+ .where(
+ and(
+ eq(core_payments_fulfillments.purchaseId, purchaseId),
+ eq(core_payments_fulfillments.effect, effect),
+ ),
+ )
+ .returning({ id: core_payments_fulfillments.id });
+ id = bumped?.id;
+ }
+
+ if (!id) return;
+
+ if (affectsPurchaseStatus(effect)) {
+ await tx
+ .update(core_payments_purchases)
+ .set({ fulfillmentStatus: "pending" })
+ .where(eq(core_payments_purchases.id, purchaseId));
+ }
+
+ await c.get("queue").dispatch({
+ maxAttempts: 5,
+ name: FULFILL_TASK,
+ payload: { fulfillmentId: id },
+ pluginId: "@vitnode/core",
+ tx,
+ });
+};
+
+const recordFailure = async (
+ c: PaymentsContext,
+ fulfillment: { effect: string; id: number; purchaseId: number },
+ error: unknown,
+): Promise => {
+ const db = c.get("db");
+ const message = describePaymentsError(error);
+
+ await db
+ .update(core_payments_fulfillments)
+ .set({
+ attempts: sql`${core_payments_fulfillments.attempts} + 1`,
+ lastError: message,
+ status: "failed",
+ })
+ .where(eq(core_payments_fulfillments.id, fulfillment.id));
+
+ // The payment stays paid. Only the fulfillment is marked failed.
+ if (affectsPurchaseStatus(fulfillment.effect)) {
+ await db
+ .update(core_payments_purchases)
+ .set({ fulfillmentStatus: "failed", lastError: message })
+ .where(eq(core_payments_purchases.id, fulfillment.purchaseId));
+ }
+
+ await c
+ .get("log")
+ .error(
+ `[Payments] Fulfillment ${fulfillment.id} (${fulfillment.effect}) failed: ${message}`,
+ );
+};
+
+/**
+ * Runs one fulfillment. Safe to call any number of times: the row is locked
+ * for the handler's transaction, and a generation that already completed is
+ * skipped. The plugin's write and the "completed" mark commit together, so a
+ * crash in between leaves nothing granted and the retry starts clean.
+ *
+ * Throws on a retryable failure so the queue tries again; returns after
+ * recording a permanent one (for example, the offer is no longer registered).
+ */
+export const runFulfillment = async (
+ c: PaymentsContext,
+ fulfillmentId: number,
+): Promise<"completed" | "failed" | "skipped"> => {
+ const db = c.get("db");
+ const [row] = await db
+ .select({
+ effect: core_payments_fulfillments.effect,
+ id: core_payments_fulfillments.id,
+ offerId: core_payments_purchases.offerId,
+ pluginId: core_payments_purchases.pluginId,
+ purchaseId: core_payments_fulfillments.purchaseId,
+ })
+ .from(core_payments_fulfillments)
+ .innerJoin(
+ core_payments_purchases,
+ eq(core_payments_purchases.id, core_payments_fulfillments.purchaseId),
+ )
+ .where(eq(core_payments_fulfillments.id, fulfillmentId))
+ .limit(1);
+
+ if (!row) return "skipped";
+
+ const registered = findRegisteredOffer(c, row.pluginId, row.offerId);
+
+ if (!registered) {
+ // Never substitute another handler or grant something else: the purchase
+ // stays paid and the gap is visible in the AdminCP until the plugin is back.
+ await recordFailure(
+ c,
+ row,
+ new PaymentsPermanentError(
+ `Offer "${row.pluginId}:${row.offerId}" is not registered - its plugin is disabled or the offer was removed. Nothing was granted; re-enable it and retry.`,
+ ),
+ );
+
+ return "failed";
+ }
+
+ const { offer } = registered;
+ let outcome: "completed" | "skipped" = "skipped";
+ let completedEffect: null | string = null;
+
+ try {
+ await db.transaction(async tx => {
+ // Purchase first, like every payment writer, so two writers never wait
+ // on each other's second lock.
+ const [purchase] = await tx
+ .select()
+ .from(core_payments_purchases)
+ .where(eq(core_payments_purchases.id, row.purchaseId))
+ .for("update");
+
+ if (!purchase) return;
+
+ const [locked] = await tx
+ .select()
+ .from(core_payments_fulfillments)
+ .where(eq(core_payments_fulfillments.id, fulfillmentId))
+ .for("update");
+
+ if (!locked || locked.completedGeneration >= locked.generation) return;
+
+ if (locked.effect === FULFILLMENT_EFFECTS.grant) {
+ if (offer.mode !== "one_time") {
+ throw new PaymentsPermanentError(
+ `Offer "${row.pluginId}:${row.offerId}" is no longer a one-time offer.`,
+ );
+ }
+
+ await offer.onPaid({ c, purchase: purchaseSnapshot(purchase), tx });
+ } else if (locked.effect.startsWith("refund:")) {
+ if (offer.mode === "one_time" && offer.onRefunded) {
+ await offer.onRefunded({
+ c,
+ purchase: purchaseSnapshot(purchase),
+ refund: {
+ amount: Number(locked.payload.amount ?? 0),
+ id: locked.effect.slice("refund:".length),
+ },
+ tx,
+ });
+ }
+ } else if (locked.effect === FULFILLMENT_EFFECTS.subscription) {
+ if (offer.mode !== "subscription") {
+ throw new PaymentsPermanentError(
+ `Offer "${row.pluginId}:${row.offerId}" is no longer a subscription offer.`,
+ );
+ }
+
+ const [subscription] = await tx
+ .select()
+ .from(core_payments_subscriptions)
+ .where(eq(core_payments_subscriptions.purchaseId, purchase.id));
+
+ if (subscription) {
+ await offer.onSubscriptionChanged({
+ c,
+ subscription: subscriptionSnapshot(subscription, purchase),
+ tx,
+ });
+ }
+ }
+
+ await tx
+ .update(core_payments_fulfillments)
+ .set({
+ attempts: sql`${core_payments_fulfillments.attempts} + 1`,
+ completedAt: new Date(),
+ completedGeneration: locked.generation,
+ lastError: null,
+ status: "completed",
+ })
+ .where(eq(core_payments_fulfillments.id, locked.id));
+
+ if (affectsPurchaseStatus(locked.effect)) {
+ await tx
+ .update(core_payments_purchases)
+ .set({ fulfillmentStatus: "fulfilled", lastError: null })
+ .where(eq(core_payments_purchases.id, purchase.id));
+ completedEffect = locked.effect;
+ }
+
+ outcome = "completed";
+ });
+ } catch (error) {
+ await recordFailure(c, row, error);
+
+ if (error instanceof Error && error.name === "PaymentsPermanentError") {
+ return "failed";
+ }
+
+ throw error;
+ }
+
+ if (completedEffect === FULFILLMENT_EFFECTS.grant) {
+ const [purchase] = await db
+ .select({ publicId: core_payments_purchases.publicId })
+ .from(core_payments_purchases)
+ .where(eq(core_payments_purchases.id, row.purchaseId));
+
+ if (purchase) {
+ await emitPaymentsEvent(c, "payments.purchase.fulfilled", {
+ offerId: row.offerId,
+ pluginId: row.pluginId,
+ purchaseId: purchase.publicId,
+ });
+ }
+ }
+
+ return outcome;
+};
+
+/**
+ * Puts a failed fulfillment back on the queue. Idempotent: a fulfillment whose
+ * current generation already completed is left alone, so a double click on
+ * "Retry" cannot grant twice.
+ */
+export const retryFulfillment = async (
+ c: PaymentsContext,
+ fulfillmentId: number,
+): Promise =>
+ await c.get("db").transaction(async tx => {
+ const [target] = await tx
+ .select({ purchaseId: core_payments_fulfillments.purchaseId })
+ .from(core_payments_fulfillments)
+ .where(eq(core_payments_fulfillments.id, fulfillmentId));
+
+ if (!target) return false;
+
+ await tx
+ .select({ id: core_payments_purchases.id })
+ .from(core_payments_purchases)
+ .where(eq(core_payments_purchases.id, target.purchaseId))
+ .for("update");
+
+ const [row] = await tx
+ .update(core_payments_fulfillments)
+ .set({ status: "pending" })
+ .where(
+ and(
+ eq(core_payments_fulfillments.id, fulfillmentId),
+ sql`${core_payments_fulfillments.completedGeneration} < ${core_payments_fulfillments.generation}`,
+ ),
+ )
+ .returning({
+ effect: core_payments_fulfillments.effect,
+ purchaseId: core_payments_fulfillments.purchaseId,
+ });
+
+ if (!row) return false;
+
+ if (affectsPurchaseStatus(row.effect)) {
+ await tx
+ .update(core_payments_purchases)
+ .set({ fulfillmentStatus: "pending" })
+ .where(eq(core_payments_purchases.id, row.purchaseId));
+ }
+
+ await c.get("queue").dispatch({
+ maxAttempts: 5,
+ name: FULFILL_TASK,
+ payload: { fulfillmentId },
+ pluginId: "@vitnode/core",
+ tx,
+ });
+
+ return true;
+ });
diff --git a/packages/vitnode/src/api/models/payments/reconcile.ts b/packages/vitnode/src/api/models/payments/reconcile.ts
new file mode 100644
index 0000000000..2daf9d2643
--- /dev/null
+++ b/packages/vitnode/src/api/models/payments/reconcile.ts
@@ -0,0 +1,271 @@
+import { and, asc, eq, inArray, lt, lte } from "drizzle-orm";
+
+import {
+ core_payments_checkouts,
+ core_payments_fulfillments,
+ core_payments_purchases,
+ core_payments_subscriptions,
+ core_payments_webhook_events,
+} from "@/database/payments";
+
+import type { PaymentsContext } from "./shared";
+
+import { FULFILL_TASK } from "./fulfillment";
+import { describePaymentsError, providerForRow } from "./shared";
+import { refreshPurchase, syncSubscription } from "./sync";
+import { PROCESS_EVENT_TASK } from "./webhooks";
+
+/** Rows looked at per kind per run - a run never walks the whole history. */
+export const RECONCILE_BATCH = 25;
+const RECONCILE_LOCK = "payments:reconcile";
+const MINUTE = 60_000;
+/** Queue work older than this with no progress is assumed lost and re-queued. */
+const STUCK_AFTER_MS = 30 * MINUTE;
+/** Backoff after a reconciliation attempt that could not reach the provider. */
+const RETRY_AFTER_MS = 60 * MINUTE;
+const RETENTION_DELETE_BATCH = 500;
+
+export interface ReconcileReport {
+ purchases: number;
+ queued: number;
+ subscriptions: number;
+ trimmedEvents: number;
+}
+
+const reconcilePurchases = async (
+ c: PaymentsContext,
+ now: Date,
+): Promise => {
+ const db = c.get("db");
+ const due = await db
+ .select()
+ .from(core_payments_purchases)
+ .where(
+ and(
+ inArray(core_payments_purchases.paymentStatus, [
+ "awaiting_payment",
+ "processing",
+ ]),
+ lte(core_payments_purchases.nextCheckAt, now),
+ ),
+ )
+ .orderBy(asc(core_payments_purchases.nextCheckAt))
+ .limit(RECONCILE_BATCH);
+
+ for (const purchase of due) {
+ try {
+ const provider = providerForRow(c, purchase);
+ const refreshed = await refreshPurchase(c, provider, purchase);
+
+ if (
+ refreshed.paymentStatus === "awaiting_payment" &&
+ refreshed.nextCheckAt?.getTime() === purchase.nextCheckAt?.getTime()
+ ) {
+ // Nothing the provider knows about (the create never landed) and the
+ // checkout window is long gone - the attempt is over.
+ const [live] = await db
+ .select({ id: core_payments_checkouts.id })
+ .from(core_payments_checkouts)
+ .where(
+ and(
+ eq(core_payments_checkouts.purchaseId, purchase.id),
+ eq(core_payments_checkouts.status, "open"),
+ ),
+ )
+ .limit(1);
+
+ await db
+ .update(core_payments_purchases)
+ .set(
+ live
+ ? { nextCheckAt: new Date(now.getTime() + RETRY_AFTER_MS) }
+ : { nextCheckAt: null, paymentStatus: "expired" },
+ )
+ .where(
+ and(
+ eq(core_payments_purchases.id, purchase.id),
+ eq(core_payments_purchases.paymentStatus, "awaiting_payment"),
+ ),
+ );
+ }
+ } catch (error) {
+ await db
+ .update(core_payments_purchases)
+ .set({
+ lastError: describePaymentsError(error),
+ nextCheckAt: new Date(now.getTime() + RETRY_AFTER_MS),
+ })
+ .where(eq(core_payments_purchases.id, purchase.id));
+ }
+ }
+
+ return due.length;
+};
+
+const reconcileSubscriptions = async (
+ c: PaymentsContext,
+ now: Date,
+): Promise => {
+ const db = c.get("db");
+ const due = await db
+ .select()
+ .from(core_payments_subscriptions)
+ .where(lte(core_payments_subscriptions.nextCheckAt, now))
+ .orderBy(asc(core_payments_subscriptions.nextCheckAt))
+ .limit(RECONCILE_BATCH);
+
+ for (const subscription of due) {
+ try {
+ await syncSubscription(
+ c,
+ providerForRow(c, subscription),
+ subscription.externalId,
+ );
+ } catch (error) {
+ await db
+ .update(core_payments_subscriptions)
+ .set({
+ lastError: describePaymentsError(error),
+ nextCheckAt: new Date(now.getTime() + RETRY_AFTER_MS),
+ })
+ .where(eq(core_payments_subscriptions.id, subscription.id));
+ }
+ }
+
+ return due.length;
+};
+
+/**
+ * Queue rows are deleted a week after they finish, and a worker can die
+ * mid-task - so pending payment work that has not moved for a while is put
+ * back on the queue. Running it twice is harmless; never running it is not.
+ */
+const requeueStuckWork = async (
+ c: PaymentsContext,
+ now: Date,
+): Promise => {
+ const db = c.get("db");
+ const cutoff = new Date(now.getTime() - STUCK_AFTER_MS);
+
+ const fulfillments = await db
+ .update(core_payments_fulfillments)
+ .set({ updatedAt: now })
+ .where(
+ inArray(
+ core_payments_fulfillments.id,
+ db
+ .select({ id: core_payments_fulfillments.id })
+ .from(core_payments_fulfillments)
+ .where(
+ and(
+ eq(core_payments_fulfillments.status, "pending"),
+ lt(core_payments_fulfillments.updatedAt, cutoff),
+ ),
+ )
+ .limit(RECONCILE_BATCH),
+ ),
+ )
+ .returning({ id: core_payments_fulfillments.id });
+
+ const events = await db
+ .update(core_payments_webhook_events)
+ .set({ receivedAt: now })
+ .where(
+ inArray(
+ core_payments_webhook_events.id,
+ db
+ .select({ id: core_payments_webhook_events.id })
+ .from(core_payments_webhook_events)
+ .where(
+ and(
+ eq(core_payments_webhook_events.status, "pending"),
+ lt(core_payments_webhook_events.receivedAt, cutoff),
+ ),
+ )
+ .limit(RECONCILE_BATCH),
+ ),
+ )
+ .returning({ id: core_payments_webhook_events.id });
+
+ for (const { id } of fulfillments) {
+ await c.get("queue").dispatch({
+ maxAttempts: 5,
+ name: FULFILL_TASK,
+ payload: { fulfillmentId: id },
+ pluginId: "@vitnode/core",
+ });
+ }
+
+ for (const { id } of events) {
+ await c.get("queue").dispatch({
+ maxAttempts: 8,
+ name: PROCESS_EVENT_TASK,
+ payload: { eventId: id },
+ pluginId: "@vitnode/core",
+ });
+ }
+
+ return fulfillments.length + events.length;
+};
+
+/** Processed events lose their row after the retention window. Failed ones stay. */
+const trimProcessedEvents = async (
+ c: PaymentsContext,
+ now: Date,
+): Promise => {
+ const config = c.get("core").payments.config;
+ if (!config) return 0;
+
+ const db = c.get("db");
+ const cutoff = new Date(
+ now.getTime() - config.webhookRetentionDays * 24 * 60 * MINUTE,
+ );
+ const deleted = await db
+ .delete(core_payments_webhook_events)
+ .where(
+ inArray(
+ core_payments_webhook_events.id,
+ db
+ .select({ id: core_payments_webhook_events.id })
+ .from(core_payments_webhook_events)
+ .where(
+ and(
+ eq(core_payments_webhook_events.status, "processed"),
+ lt(core_payments_webhook_events.processedAt, cutoff),
+ ),
+ )
+ .limit(RETENTION_DELETE_BATCH),
+ ),
+ )
+ .returning({ id: core_payments_webhook_events.id });
+
+ return deleted.length;
+};
+
+/**
+ * One bounded pass over payment work that may have missed its webhook: open
+ * checkouts past their window, payments stuck in processing, subscriptions due
+ * a renewal check, and queue work that never ran. Every query is limited and
+ * index-backed, and each row schedules its own next check.
+ */
+export const reconcilePayments = async (
+ c: PaymentsContext,
+ now = new Date(),
+): Promise => {
+ // Without config there is nothing to talk to - history is left as it is.
+ if (!c.get("core").payments.config) return null;
+
+ const cache = c.get("cache");
+ if (!(await cache.acquireLock(RECONCILE_LOCK, 240))) return null;
+
+ try {
+ return {
+ purchases: await reconcilePurchases(c, now),
+ queued: await requeueStuckWork(c, now),
+ subscriptions: await reconcileSubscriptions(c, now),
+ trimmedEvents: await trimProcessedEvents(c, now),
+ };
+ } finally {
+ await cache.releaseLock(RECONCILE_LOCK);
+ }
+};
diff --git a/packages/vitnode/src/api/models/payments/shared.ts b/packages/vitnode/src/api/models/payments/shared.ts
new file mode 100644
index 0000000000..7efac2fecc
--- /dev/null
+++ b/packages/vitnode/src/api/models/payments/shared.ts
@@ -0,0 +1,200 @@
+import type { Context } from "hono";
+
+import { HTTPException } from "hono/http-exception";
+import { createHash } from "node:crypto";
+
+import type { EnvVitNode } from "@/api/middlewares/global.middleware";
+import type { VitNodeEventName, VitNodeEvents } from "@/api/models/events";
+import type {
+ core_payments_purchases,
+ core_payments_subscriptions,
+} from "@/database/payments";
+import type { ResolvedPaymentsConfig } from "@/payments/config";
+import type {
+ PaymentPurchaseSnapshot,
+ PaymentSubscriptionSnapshot,
+ RegisteredPaymentOffer,
+} from "@/payments/offer";
+import type { PaymentProvider } from "@/payments/provider";
+
+import { CONFIG } from "@/lib/config";
+import { paymentOfferKey } from "@/payments/offer";
+import { isPaymentProviderError } from "@/payments/provider";
+
+export type PaymentsContext = Context;
+export type PurchaseRow = typeof core_payments_purchases.$inferSelect;
+export type SubscriptionRow = typeof core_payments_subscriptions.$inferSelect;
+
+/**
+ * A problem retrying will not fix - an event for an object this install never
+ * created, a provider that is no longer configured. Recorded for the AdminCP
+ * and not retried by the queue.
+ */
+export class PaymentsPermanentError extends Error {
+ constructor(message: string) {
+ super(message);
+ this.name = "PaymentsPermanentError";
+ }
+}
+
+export const isPermanentPaymentsError = (error: unknown): boolean =>
+ (error instanceof Error && error.name === "PaymentsPermanentError") ||
+ (isPaymentProviderError(error) && !error.retryable);
+
+/** Errors as text for an admin: the message only - never a payload or a key. */
+export const describePaymentsError = (error: unknown): string =>
+ (error instanceof Error ? error.message : String(error)).slice(0, 1000);
+
+/**
+ * An API error with a stable machine-readable `code` next to the human
+ * `message` the shared `readApiErrorMessage` already shows.
+ */
+export const paymentsHttpError = (
+ status: 400 | 401 | 403 | 404 | 409 | 502 | 503,
+ code: string,
+ message: string,
+ extra: Record = {},
+): HTTPException =>
+ new HTTPException(status, {
+ message,
+ res: new Response(JSON.stringify({ code, message, ...extra }), {
+ headers: { "Content-Type": "application/json" },
+ status,
+ }),
+ });
+
+export const requirePaymentsConfig = (
+ c: PaymentsContext,
+): ResolvedPaymentsConfig => {
+ const config = c.get("core").payments.config;
+
+ if (!config) {
+ throw paymentsHttpError(
+ 503,
+ "payments_disabled",
+ "Payments are not enabled on this site.",
+ );
+ }
+
+ return config;
+};
+
+export const requirePaymentsUser = (c: PaymentsContext) => {
+ const user = c.get("user");
+ if (!user) throw paymentsHttpError(401, "unauthorized", "Sign in first.");
+
+ return user;
+};
+
+export const findRegisteredOffer = (
+ c: PaymentsContext,
+ pluginId: string,
+ offerId: string,
+): RegisteredPaymentOffer | undefined =>
+ c.get("core").payments.offers.get(paymentOfferKey(pluginId, offerId));
+
+/**
+ * The provider a stored row belongs to. History stays readable without it,
+ * but ongoing work for that row cannot continue - and says why.
+ */
+export const providerForRow = (
+ c: PaymentsContext,
+ row: { provider: string; providerScope: string },
+): PaymentProvider => {
+ const provider = c
+ .get("core")
+ .payments.config?.providers.find(item => item.id === row.provider);
+
+ if (!provider) {
+ throw new PaymentsPermanentError(
+ `Payment provider "${row.provider}" is not configured any more. Register it in "payments.providers" again to finish this work - see the payments troubleshooting docs before uninstalling a provider with active subscriptions.`,
+ );
+ }
+
+ if (provider.scope !== row.providerScope) {
+ throw new PaymentsPermanentError(
+ `This record belongs to the "${row.providerScope}" environment of "${row.provider}", but the configured keys are for "${provider.scope}".`,
+ );
+ }
+
+ return provider;
+};
+
+/** A fixed-width hash of the resolved operation, for idempotency checks. */
+export const hashCheckoutRequest = (parts: (null | string)[]): string =>
+ createHash("sha256").update(JSON.stringify(parts)).digest("hex");
+
+/**
+ * An absolute URL on the configured web origin. The path always comes from
+ * server-side declarations, so there is nothing to redirect elsewhere.
+ */
+export const webUrl = (
+ path: string,
+ params: Record = {},
+): string => {
+ const url = new URL(path, CONFIG.web.origin);
+
+ if (url.origin !== CONFIG.web.origin) {
+ throw new Error(
+ `Refusing to build a return URL outside ${CONFIG.web.origin}.`,
+ );
+ }
+
+ for (const [key, value] of Object.entries(params)) {
+ url.searchParams.set(key, value);
+ }
+
+ return url.toString();
+};
+
+export const purchaseSnapshot = (
+ row: PurchaseRow,
+): PaymentPurchaseSnapshot => ({
+ amount: row.amount,
+ currency: row.currency,
+ fulfillmentStatus: row.fulfillmentStatus,
+ id: row.publicId,
+ interval: row.interval,
+ offerId: row.offerId,
+ offerName: row.offerName,
+ paidAt: row.paidAt,
+ pluginId: row.pluginId,
+ refundedAmount: row.refundedAmount,
+ refundStatus: row.refundStatus,
+ userId: row.userId,
+});
+
+export const subscriptionSnapshot = (
+ row: SubscriptionRow,
+ purchase: Pick,
+): PaymentSubscriptionSnapshot => ({
+ amount: row.amount,
+ cancelAtPeriodEnd: row.cancelAtPeriodEnd,
+ currency: row.currency,
+ endedAt: row.endedAt,
+ id: row.publicId,
+ interval: row.interval,
+ offerId: row.offerId,
+ paidThrough: row.paidThrough,
+ pluginId: row.pluginId,
+ purchaseId: purchase.publicId,
+ status: row.status,
+ userId: row.userId,
+});
+
+/** Best effort: a failing listener never undoes a committed payment. */
+export const emitPaymentsEvent = async (
+ c: PaymentsContext,
+ name: K,
+ payload: VitNodeEvents[K],
+): Promise => {
+ try {
+ await c.get("events").emit(name, payload, { pluginId: "@vitnode/core" });
+ } catch (error) {
+ await c
+ .get("log")
+ .warn(
+ `[Payments] Event "${name}" was not delivered: ${describePaymentsError(error)}`,
+ );
+ }
+};
diff --git a/packages/vitnode/src/api/models/payments/subscriptions.integration.test.ts b/packages/vitnode/src/api/models/payments/subscriptions.integration.test.ts
new file mode 100644
index 0000000000..e39dae41d2
--- /dev/null
+++ b/packages/vitnode/src/api/models/payments/subscriptions.integration.test.ts
@@ -0,0 +1,411 @@
+// @vitest-environment node
+import { eq } from "drizzle-orm";
+import { HTTPException } from "hono/http-exception";
+import { afterAll, beforeAll, expect, it } from "vitest";
+
+import {
+ core_payments_invoices,
+ core_payments_purchases,
+ core_payments_subscriptions,
+} from "@/database/payments";
+import { definePaymentOffer } from "@/payments/offer";
+import { subscriptionDisplayState } from "@/payments/status";
+import {
+ createPaymentsHarness,
+ must,
+ type PaymentsHarness,
+ TEST_PLUGIN_ID,
+} from "@/tests/payments";
+import { describePostgres } from "@/tests/postgres";
+
+import { createPortalSession, startCheckout } from "./checkout";
+import { reconcilePayments } from "./reconcile";
+import { refreshPurchase, syncInvoice, syncSubscription } from "./sync";
+
+const DAY = 24 * 60 * 60 * 1000;
+
+/** Prices that a test changes after people subscribed. */
+const repricedPlan = definePaymentOffer({
+ id: "repriced",
+ intervals: { month: { PLN: 1900 } },
+ mode: "subscription",
+ name: "Repriced plan",
+ onSubscriptionChanged: async () => {},
+ returnPath: "/example/payments",
+});
+
+const errorCode = async (promise: Promise) => {
+ try {
+ await promise;
+ } catch (error) {
+ if (error instanceof HTTPException) {
+ return ((await error.getResponse().json()) as { code: string }).code;
+ }
+ throw error;
+ }
+ throw new Error("Expected the call to fail.");
+};
+
+describePostgres("subscriptions", () => {
+ let h: PaymentsHarness;
+
+ beforeAll(async () => {
+ h = await createPaymentsHarness({ offers: [repricedPlan] });
+ });
+
+ afterAll(async () => {
+ await h?.database.drop();
+ });
+
+ const subscribe = async (
+ interval: "month" | "year" = "month",
+ offerId = "plan",
+ ) => {
+ const user = await h.createUser();
+ const { purchase } = await startCheckout(h.c, user, {
+ currency: "PLN",
+ interval,
+ offerId,
+ pluginId: TEST_PLUGIN_ID,
+ });
+ const checkout = must(
+ [...h.fake.checkouts.values()].find(
+ item => item.reference === purchase.publicId,
+ ),
+ );
+ const { subscriptionId } = h.fake.complete(checkout.id);
+ await refreshPurchase(h.c, h.fake.provider, purchase);
+ await h.drainQueue();
+
+ return { purchase, subscriptionId: must(subscriptionId), user };
+ };
+
+ const local = async (externalId: string) =>
+ must(
+ (
+ await h.database.db
+ .select()
+ .from(core_payments_subscriptions)
+ .where(eq(core_payments_subscriptions.externalId, externalId))
+ )[0],
+ );
+
+ it("grants access from the first verified payment until the period it paid for", async () => {
+ const { purchase, subscriptionId, user } = await subscribe("year");
+ const stored = await local(subscriptionId);
+ const providerEnd = must(
+ h.fake.subscriptions.get(subscriptionId),
+ ).currentPeriodEnd;
+
+ expect(stored).toMatchObject({
+ amount: 19000,
+ currency: "PLN",
+ interval: "year",
+ paidThrough: providerEnd,
+ status: "active",
+ });
+ const [purchaseRow] = await h.database.db
+ .select()
+ .from(core_payments_purchases)
+ .where(eq(core_payments_purchases.id, purchase.id));
+ expect(purchaseRow).toMatchObject({
+ fulfillmentStatus: "fulfilled",
+ paymentStatus: "paid",
+ });
+ expect((await h.access(user.id))[0].accessUntil).toEqual(providerEnd);
+ });
+
+ it("extends access once per verified renewal, however often it is reported", async () => {
+ const { subscriptionId, user } = await subscribe();
+ const invoiceId = h.fake.renew(subscriptionId, "paid");
+ const renewedEnd = must(h.fake.invoices.get(invoiceId)).periodEnd;
+
+ await syncInvoice(h.c, h.fake.provider, invoiceId, "paid");
+ await syncInvoice(h.c, h.fake.provider, invoiceId, "paid");
+ await syncSubscription(h.c, h.fake.provider, subscriptionId);
+ await h.drainQueue();
+
+ expect((await local(subscriptionId)).paidThrough).toEqual(renewedEnd);
+ expect((await h.access(user.id))[0].accessUntil).toEqual(renewedEnd);
+ expect(
+ await h.database.db
+ .select()
+ .from(core_payments_invoices)
+ .where(eq(core_payments_invoices.externalId, invoiceId)),
+ ).toHaveLength(1);
+ });
+
+ it("keeps the paid period when a renewal fails, and asks for attention", async () => {
+ const { subscriptionId, user } = await subscribe();
+ const paidThrough = (await local(subscriptionId)).paidThrough;
+ const invoiceId = h.fake.renew(subscriptionId, "failed");
+
+ await syncInvoice(h.c, h.fake.provider, invoiceId, "payment_failed");
+ await syncSubscription(h.c, h.fake.provider, subscriptionId);
+ await h.drainQueue();
+
+ const stored = await local(subscriptionId);
+ expect(stored.status).toBe("past_due");
+ expect(stored.paidThrough).toEqual(paidThrough);
+ expect(subscriptionDisplayState(stored)).toBe("needs_attention");
+ expect((await h.access(user.id))[0].accessUntil).toEqual(paidThrough);
+
+ const [invoice] = await h.database.db
+ .select()
+ .from(core_payments_invoices)
+ .where(eq(core_payments_invoices.externalId, invoiceId));
+ expect(invoice.status).toBe("payment_failed");
+ });
+
+ it("records a renewal that needs the customer to act", async () => {
+ const { subscriptionId } = await subscribe();
+ const invoiceId = h.fake.renew(subscriptionId, "action_required");
+
+ await syncInvoice(h.c, h.fake.provider, invoiceId, "action_required");
+
+ const [invoice] = await h.database.db
+ .select()
+ .from(core_payments_invoices)
+ .where(eq(core_payments_invoices.externalId, invoiceId));
+ expect(invoice.status).toBe("action_required");
+ });
+
+ it("keeps access until the period ends after a cancellation is scheduled", async () => {
+ const { subscriptionId, user } = await subscribe();
+ const before = await local(subscriptionId);
+ const remote = must(h.fake.subscriptions.get(subscriptionId));
+ remote.cancelAtPeriodEnd = true;
+ remote.cancelAt = remote.currentPeriodEnd;
+
+ await syncSubscription(h.c, h.fake.provider, subscriptionId);
+ await h.drainQueue();
+
+ const stored = await local(subscriptionId);
+ expect(subscriptionDisplayState(stored)).toBe("cancellation_scheduled");
+ expect(stored.paidThrough).toEqual(before.paidThrough);
+ expect((await h.access(user.id))[0].accessUntil).toEqual(
+ before.paidThrough,
+ );
+ });
+
+ it("ends access when the subscription ends", async () => {
+ const { subscriptionId, user } = await subscribe();
+ const remote = must(h.fake.subscriptions.get(subscriptionId));
+ const endedAt = new Date(Date.now() + DAY);
+ remote.status = "canceled";
+ remote.providerStatus = "canceled";
+ remote.endedAt = endedAt;
+
+ await syncSubscription(h.c, h.fake.provider, subscriptionId);
+ await h.drainQueue();
+
+ const stored = await local(subscriptionId);
+ expect(subscriptionDisplayState(stored)).toBe("ended");
+ expect(stored.nextCheckAt).toBeNull();
+ expect((await h.access(user.id))[0].accessUntil).toEqual(endedAt);
+ });
+
+ it("never brings an ended subscription back from a stale read", async () => {
+ const { subscriptionId } = await subscribe();
+ const remote = must(h.fake.subscriptions.get(subscriptionId));
+ remote.status = "canceled";
+ remote.providerStatus = "canceled";
+ remote.endedAt = new Date();
+ await syncSubscription(h.c, h.fake.provider, subscriptionId);
+
+ remote.status = "active";
+ remote.providerStatus = "active";
+ remote.endedAt = null;
+ await syncSubscription(h.c, h.fake.provider, subscriptionId);
+
+ expect((await local(subscriptionId)).status).toBe("canceled");
+ });
+
+ it("keeps a newer sync when an older read finishes last", async () => {
+ const { subscriptionId } = await subscribe();
+ const newer = new Date(Date.now() + 60_000);
+ await h.database.db
+ .update(core_payments_subscriptions)
+ .set({ syncedAt: newer })
+ .where(eq(core_payments_subscriptions.externalId, subscriptionId));
+ const remote = must(h.fake.subscriptions.get(subscriptionId));
+ remote.cancelAtPeriodEnd = true;
+
+ await syncSubscription(h.c, h.fake.provider, subscriptionId);
+
+ expect(await local(subscriptionId)).toMatchObject({
+ cancelAtPeriodEnd: false,
+ syncedAt: newer,
+ });
+ });
+
+ it("flags a second provider subscription for the same purchase", async () => {
+ const { purchase, subscriptionId } = await subscribe();
+ const extra = {
+ ...must(h.fake.subscriptions.get(subscriptionId)),
+ id: `${subscriptionId}_extra`,
+ latestInvoiceId: null,
+ };
+ h.fake.subscriptions.set(extra.id, extra);
+
+ await expect(
+ syncSubscription(h.c, h.fake.provider, extra.id),
+ ).rejects.toThrow(/second subscription/);
+
+ const [stored] = await h.database.db
+ .select()
+ .from(core_payments_purchases)
+ .where(eq(core_payments_purchases.id, purchase.id));
+ expect(stored.lastError).toMatch(extra.id);
+ expect((await local(subscriptionId)).status).toBe("active");
+ expect(
+ await h.database.db
+ .select()
+ .from(core_payments_subscriptions)
+ .where(eq(core_payments_subscriptions.externalId, extra.id)),
+ ).toHaveLength(0);
+ });
+
+ it("refuses a second subscription to the same plan, even concurrently", async () => {
+ const user = await h.createUser();
+ const request = {
+ currency: "PLN",
+ interval: "month" as const,
+ offerId: "plan",
+ pluginId: TEST_PLUGIN_ID,
+ };
+
+ await Promise.allSettled(
+ Array.from({ length: 4 }, async () => {
+ await startCheckout(h.forkContext(), user, request);
+ }),
+ );
+ const purchases = await h.database.db
+ .select()
+ .from(core_payments_purchases)
+ .where(eq(core_payments_purchases.userId, user.id));
+ expect(purchases).toHaveLength(1);
+
+ const checkout = must(
+ [...h.fake.checkouts.values()].find(
+ item => item.reference === purchases[0].publicId,
+ ),
+ );
+ h.fake.complete(checkout.id);
+ await refreshPurchase(h.c, h.fake.provider, purchases[0]);
+
+ expect(
+ await errorCode(
+ startCheckout(h.c, user, { ...request, interval: "year" }),
+ ),
+ ).toBe("already_subscribed");
+ });
+
+ it("keeps existing subscribers on their price when the offer is repriced", async () => {
+ const { subscriptionId } = await subscribe("month", "repriced");
+ (repricedPlan.intervals.month as Record).PLN = 2500;
+
+ await syncSubscription(h.c, h.fake.provider, subscriptionId);
+ const newcomer = await h.createUser();
+ const { purchase } = await startCheckout(h.c, newcomer, {
+ currency: "PLN",
+ interval: "month",
+ offerId: "repriced",
+ pluginId: TEST_PLUGIN_ID,
+ });
+
+ expect((await local(subscriptionId)).amount).toBe(1900);
+ expect(purchase.amount).toBe(2500);
+ });
+
+ it("records an invoice that arrives before its checkout was processed", async () => {
+ const user = await h.createUser();
+ const { purchase } = await startCheckout(h.c, user, {
+ currency: "USD",
+ interval: "month",
+ offerId: "plan",
+ pluginId: TEST_PLUGIN_ID,
+ });
+ const checkout = must(
+ [...h.fake.checkouts.values()].find(
+ item => item.reference === purchase.publicId,
+ ),
+ );
+ const { invoiceId, subscriptionId } = h.fake.complete(checkout.id);
+
+ await syncInvoice(h.c, h.fake.provider, must(invoiceId), "paid");
+
+ expect((await local(must(subscriptionId))).paidThrough).not.toBeNull();
+ const [row] = await h.database.db
+ .select()
+ .from(core_payments_purchases)
+ .where(eq(core_payments_purchases.id, purchase.id));
+ expect(row.paymentStatus).toBe("paid");
+ });
+
+ it("catches a renewal whose webhook never arrived", async () => {
+ const { subscriptionId, user } = await subscribe();
+ const invoiceId = h.fake.renew(subscriptionId, "paid");
+ const renewedEnd = must(h.fake.invoices.get(invoiceId)).periodEnd;
+ await h.database.db
+ .update(core_payments_subscriptions)
+ .set({ nextCheckAt: new Date(Date.now() - 1000) })
+ .where(eq(core_payments_subscriptions.externalId, subscriptionId));
+
+ const report = await reconcilePayments(h.c);
+ await h.drainQueue();
+
+ expect(report?.subscriptions).toBeGreaterThanOrEqual(1);
+ expect((await local(subscriptionId)).paidThrough).toEqual(renewedEnd);
+ expect((await h.access(user.id))[0].accessUntil).toEqual(renewedEnd);
+ });
+
+ it("expires an abandoned checkout during reconciliation", async () => {
+ const user = await h.createUser();
+ const { purchase } = await startCheckout(h.c, user, {
+ currency: "PLN",
+ interval: null,
+ offerId: "lifetime",
+ pluginId: TEST_PLUGIN_ID,
+ });
+ const checkout = must(
+ [...h.fake.checkouts.values()].find(
+ item => item.reference === purchase.publicId,
+ ),
+ );
+ h.fake.expireCheckout(checkout.id);
+ await h.database.db
+ .update(core_payments_purchases)
+ .set({ nextCheckAt: new Date(Date.now() - 1000) })
+ .where(eq(core_payments_purchases.id, purchase.id));
+
+ await reconcilePayments(h.c);
+
+ const [row] = await h.database.db
+ .select()
+ .from(core_payments_purchases)
+ .where(eq(core_payments_purchases.id, purchase.id));
+ expect(row).toMatchObject({ nextCheckAt: null, paymentStatus: "expired" });
+ });
+
+ it("opens the billing portal only for the user's own customer", async () => {
+ const { user } = await subscribe();
+ const stranger = await h.createUser();
+
+ const url = await createPortalSession(h.c, user.id, {
+ returnPath: "/settings/billing",
+ });
+ expect(url).toContain(`cus_${user.id}`);
+ expect(new URL(url).searchParams.get("return")).toBe(
+ "http://localhost:3000/settings/billing",
+ );
+
+ expect(
+ await errorCode(
+ createPortalSession(h.c, stranger.id, {
+ returnPath: "/settings/billing",
+ }),
+ ),
+ ).toBe("no_billing_account");
+ });
+});
diff --git a/packages/vitnode/src/api/models/payments/sync.ts b/packages/vitnode/src/api/models/payments/sync.ts
new file mode 100644
index 0000000000..015b50befe
--- /dev/null
+++ b/packages/vitnode/src/api/models/payments/sync.ts
@@ -0,0 +1,938 @@
+import { and, desc, eq, inArray, notInArray, sql } from "drizzle-orm";
+
+import type {
+ PaymentProvider,
+ ProviderCheckout,
+ ProviderInvoice,
+ ProviderPayment,
+ ProviderSubscription,
+} from "@/payments/provider";
+import type {
+ InvoiceStatus,
+ PurchasePaymentStatus,
+ RefundStatus,
+} from "@/payments/status";
+
+import {
+ core_payments_adjustments,
+ core_payments_checkouts,
+ core_payments_invoices,
+ core_payments_purchases,
+ core_payments_subscriptions,
+} from "@/database/payments";
+import { assertMinorUnits } from "@/payments/money";
+import {
+ nextPurchasePaymentStatus,
+ TERMINAL_SUBSCRIPTION_STATUSES,
+} from "@/payments/status";
+
+import type { PaymentsContext, PurchaseRow, SubscriptionRow } from "./shared";
+
+import { FULFILLMENT_EFFECTS, scheduleFulfillment } from "./fulfillment";
+import { emitPaymentsEvent, PaymentsPermanentError } from "./shared";
+
+const MINUTE = 60_000;
+const HOUR = 60 * MINUTE;
+
+/** How long reconciliation waits before asking about a still-processing payment. */
+export const PROCESSING_RECHECK_MS = 15 * MINUTE;
+
+const observedPaymentStatus = (
+ checkout: ProviderCheckout,
+): PurchasePaymentStatus => {
+ if (checkout.status === "expired") return "expired";
+ if (checkout.status === "open") return "awaiting_payment";
+ if (checkout.paymentStatus === "failed") return "failed";
+ if (checkout.paymentStatus === "unpaid") return "processing";
+
+ return "paid";
+};
+
+const findPurchaseForCheckout = async (
+ c: PaymentsContext,
+ provider: PaymentProvider,
+ checkout: ProviderCheckout,
+): Promise => {
+ const db = c.get("db");
+
+ if (checkout.reference && /^[0-9a-f-]{36}$/i.test(checkout.reference)) {
+ const [byReference] = await db
+ .select()
+ .from(core_payments_purchases)
+ .where(eq(core_payments_purchases.publicId, checkout.reference));
+
+ if (byReference) return byReference;
+ }
+
+ const [byCheckout] = await db
+ .select({ purchase: core_payments_purchases })
+ .from(core_payments_checkouts)
+ .innerJoin(
+ core_payments_purchases,
+ eq(core_payments_purchases.id, core_payments_checkouts.purchaseId),
+ )
+ .where(
+ and(
+ eq(core_payments_checkouts.provider, provider.id),
+ eq(core_payments_checkouts.providerScope, provider.scope),
+ eq(core_payments_checkouts.externalId, checkout.id),
+ ),
+ );
+
+ if (byCheckout) return byCheckout.purchase;
+
+ throw new PaymentsPermanentError(
+ `Checkout ${checkout.id} does not belong to any purchase on this site.`,
+ );
+};
+
+/**
+ * Applies a checkout's authoritative state to its purchase. Called from the
+ * webhook queue, reconciliation and the buyer's own status refresh - all three
+ * re-read the checkout from the provider first, so the order events arrive in
+ * does not matter and an older snapshot can never move the purchase back.
+ */
+export const applyCheckoutState = async (
+ c: PaymentsContext,
+ provider: PaymentProvider,
+ checkout: ProviderCheckout,
+): Promise<{ purchase: PurchaseRow; subscriptionId: null | string }> => {
+ const found = await findPurchaseForCheckout(c, provider, checkout);
+
+ if (
+ found.provider !== provider.id ||
+ found.providerScope !== provider.scope
+ ) {
+ throw new PaymentsPermanentError(
+ `Checkout ${checkout.id} came from "${provider.id}/${provider.scope}" but purchase ${found.publicId} was started with "${found.provider}/${found.providerScope}".`,
+ );
+ }
+
+ const observed = observedPaymentStatus(checkout);
+ let becamePaid = false;
+
+ const purchase = await c.get("db").transaction(async tx => {
+ const [current] = await tx
+ .select()
+ .from(core_payments_purchases)
+ .where(eq(core_payments_purchases.id, found.id))
+ .for("update");
+
+ // Only the purchase's newest checkout may end it. An older session that
+ // was superseded can still report "expired" after its replacement opened.
+ const attempts = await tx
+ .select({
+ externalId: core_payments_checkouts.externalId,
+ id: core_payments_checkouts.id,
+ })
+ .from(core_payments_checkouts)
+ .where(eq(core_payments_checkouts.purchaseId, current.id))
+ .orderBy(desc(core_payments_checkouts.id));
+ const latest = attempts.at(0);
+ const matched = attempts.find(
+ attempt => attempt.externalId === checkout.id,
+ );
+ const fromLatest =
+ !latest ||
+ latest.externalId === checkout.id ||
+ (!matched && latest.externalId === null);
+ const endsPurchase =
+ observed === "expired" ||
+ observed === "failed" ||
+ observed === "awaiting_payment";
+
+ const next =
+ endsPurchase && !fromLatest
+ ? current.paymentStatus
+ : nextPurchasePaymentStatus(current.paymentStatus, observed);
+ becamePaid = next === "paid" && current.paymentStatus !== "paid";
+
+ // A second session for the same purchase was paid as well. Its payment
+ // is reported, never merged into this purchase's records.
+ const paidTwice =
+ current.paymentStatus === "paid" &&
+ observed === "paid" &&
+ ((checkout.paymentId !== null &&
+ current.externalPaymentId !== null &&
+ checkout.paymentId !== current.externalPaymentId) ||
+ (!fromLatest && !!matched))
+ ? `A second checkout (${checkout.id}) for this purchase was also paid. Refund it in the provider.`
+ : null;
+
+ // A charge that does not match the snapshot is recorded, never fulfilled
+ // automatically: something outside this code changed the price.
+ const mismatch =
+ becamePaid &&
+ checkout.amountTotal !== null &&
+ (checkout.amountTotal !== current.amount ||
+ (checkout.currency !== null && checkout.currency !== current.currency))
+ ? `Charged ${checkout.amountTotal} ${checkout.currency ?? "?"} but the purchase was for ${current.amount} ${current.currency}. Fulfillment held for review.`
+ : null;
+
+ const [updated] = await tx
+ .update(core_payments_purchases)
+ .set({
+ externalCustomerId: checkout.customerId ?? current.externalCustomerId,
+ externalPaymentId: paidTwice
+ ? current.externalPaymentId
+ : (checkout.paymentId ?? current.externalPaymentId),
+ lastError: paidTwice ?? mismatch ?? current.lastError,
+ nextCheckAt:
+ next === "processing"
+ ? new Date(Date.now() + PROCESSING_RECHECK_MS)
+ : next === "awaiting_payment"
+ ? current.nextCheckAt
+ : null,
+ paidAt: becamePaid ? new Date() : current.paidAt,
+ paymentStatus: next,
+ })
+ .where(eq(core_payments_purchases.id, current.id))
+ .returning();
+
+ await tx
+ .update(core_payments_checkouts)
+ .set({
+ externalId: checkout.id,
+ status: checkout.status,
+ url: checkout.status === "open" ? checkout.url : null,
+ })
+ .where(
+ and(
+ eq(core_payments_checkouts.purchaseId, current.id),
+ eq(core_payments_checkouts.provider, provider.id),
+ eq(core_payments_checkouts.externalId, checkout.id),
+ ),
+ );
+
+ if (becamePaid && current.mode === "one_time") {
+ if (mismatch) {
+ await tx
+ .update(core_payments_purchases)
+ .set({ fulfillmentStatus: "failed" })
+ .where(eq(core_payments_purchases.id, current.id));
+ } else {
+ await scheduleFulfillment(c, tx, {
+ effect: FULFILLMENT_EFFECTS.grant,
+ purchaseId: current.id,
+ });
+ }
+ }
+
+ return updated;
+ });
+
+ if (becamePaid) {
+ await emitPaymentsEvent(c, "payments.purchase.paid", {
+ amount: purchase.amount,
+ currency: purchase.currency,
+ offerId: purchase.offerId,
+ pluginId: purchase.pluginId,
+ purchaseId: purchase.publicId,
+ userId: purchase.userId,
+ });
+ }
+
+ return { purchase, subscriptionId: checkout.subscriptionId };
+};
+
+const requireSubscriptions = (provider: PaymentProvider) => {
+ if (!provider.subscriptions) {
+ throw new PaymentsPermanentError(
+ `Provider "${provider.id}" does not support subscriptions.`,
+ );
+ }
+
+ return provider.subscriptions;
+};
+
+/** When reconciliation should look at a subscription again - bounded, never "always". */
+const nextSubscriptionCheck = (
+ state: ProviderSubscription,
+ now = Date.now(),
+): Date | null => {
+ if (TERMINAL_SUBSCRIPTION_STATUSES.includes(state.status)) return null;
+
+ if (state.status !== "active") return new Date(now + 6 * HOUR);
+
+ // Just after the period ends - which is when a renewal has to have happened.
+ const periodEnd = state.currentPeriodEnd?.getTime();
+
+ return new Date(
+ periodEnd && periodEnd > now ? periodEnd + HOUR : now + 24 * HOUR,
+ );
+};
+
+const findPurchaseForSubscription = async (
+ c: PaymentsContext,
+ provider: PaymentProvider,
+ state: ProviderSubscription,
+ checkoutPurchase?: PurchaseRow,
+): Promise => {
+ if (checkoutPurchase) return checkoutPurchase;
+
+ if (state.reference && /^[0-9a-f-]{36}$/i.test(state.reference)) {
+ const [purchase] = await c
+ .get("db")
+ .select()
+ .from(core_payments_purchases)
+ .where(
+ and(
+ eq(core_payments_purchases.publicId, state.reference),
+ eq(core_payments_purchases.provider, provider.id),
+ eq(core_payments_purchases.providerScope, provider.scope),
+ ),
+ );
+
+ if (purchase) return purchase;
+ }
+
+ throw new PaymentsPermanentError(
+ `Subscription ${state.id} was not started from a checkout on this site.`,
+ );
+};
+
+const changedForPlugin = (
+ before: SubscriptionRow | undefined,
+ after: SubscriptionRow,
+): boolean => {
+ if (!before) return true;
+
+ return (
+ before.status !== after.status ||
+ before.cancelAtPeriodEnd !== after.cancelAtPeriodEnd ||
+ before.paidThrough?.getTime() !== after.paidThrough?.getTime() ||
+ before.endedAt?.getTime() !== after.endedAt?.getTime()
+ );
+};
+
+const afterSubscriptionChange = async (
+ c: PaymentsContext,
+ row: SubscriptionRow,
+): Promise => {
+ await emitPaymentsEvent(c, "payments.subscription.updated", {
+ offerId: row.offerId,
+ paidThrough: row.paidThrough?.toISOString() ?? null,
+ pluginId: row.pluginId,
+ status: row.status,
+ subscriptionId: row.publicId,
+ });
+};
+
+/**
+ * Re-reads a subscription and its latest invoice from the provider and stores
+ * them. Handles Customer Portal changes, cancellations and ends the same way:
+ * whatever the provider says now is what is stored, and the plugin is told
+ * only when something it can act on changed.
+ */
+export const syncSubscription = async (
+ c: PaymentsContext,
+ provider: PaymentProvider,
+ subscriptionId: string,
+ { checkoutPurchase }: { checkoutPurchase?: PurchaseRow } = {},
+): Promise => {
+ const api = requireSubscriptions(provider);
+ // Taken before the read: a write that lands later reflects a newer read.
+ const readAt = new Date();
+ const state = await api.retrieve(subscriptionId);
+ const db = c.get("db");
+
+ const [existing] = await db
+ .select()
+ .from(core_payments_subscriptions)
+ .where(
+ and(
+ eq(core_payments_subscriptions.provider, provider.id),
+ eq(core_payments_subscriptions.providerScope, provider.scope),
+ eq(core_payments_subscriptions.externalId, state.id),
+ ),
+ );
+
+ const purchaseId =
+ existing?.purchaseId ??
+ (await findPurchaseForSubscription(c, provider, state, checkoutPurchase))
+ .id;
+
+ let changed = false;
+ let duplicateOf: null | string = null;
+
+ const row = await db.transaction(async tx => {
+ const [owner] = await tx
+ .select()
+ .from(core_payments_purchases)
+ .where(eq(core_payments_purchases.id, purchaseId))
+ .for("update");
+
+ const [before] = await tx
+ .select()
+ .from(core_payments_subscriptions)
+ .where(eq(core_payments_subscriptions.purchaseId, owner.id))
+ .for("update");
+
+ if (before && before.externalId !== state.id) {
+ duplicateOf = before.externalId;
+
+ return before;
+ }
+
+ // A slower sync that read the provider earlier must not overwrite a newer
+ // one, and an ended subscription never comes back to life.
+ if (
+ before &&
+ (before.syncedAt > readAt ||
+ (TERMINAL_SUBSCRIPTION_STATUSES.includes(before.status) &&
+ !TERMINAL_SUBSCRIPTION_STATUSES.includes(state.status)))
+ ) {
+ return before;
+ }
+
+ const values = {
+ cancelAt: state.cancelAt,
+ cancelAtPeriodEnd: state.cancelAtPeriodEnd,
+ canceledAt: state.canceledAt,
+ currentPeriodEnd: state.currentPeriodEnd,
+ endedAt: state.endedAt,
+ externalCustomerId: state.customerId,
+ externalPriceId: state.priceId ?? before?.externalPriceId ?? null,
+ lastError: null,
+ nextCheckAt: nextSubscriptionCheck(state),
+ providerStatus: state.providerStatus,
+ status: state.status,
+ syncedAt: readAt,
+ };
+
+ const [stored] = before
+ ? await tx
+ .update(core_payments_subscriptions)
+ .set(values)
+ .where(eq(core_payments_subscriptions.id, before.id))
+ .returning()
+ : await tx
+ .insert(core_payments_subscriptions)
+ .values({
+ ...values,
+ amount: assertMinorUnits(state.amount ?? owner.amount),
+ currency: state.currency ?? owner.currency,
+ externalId: state.id,
+ interval: state.interval ?? owner.interval ?? "month",
+ offerId: owner.offerId,
+ offerName: owner.offerName,
+ pluginId: owner.pluginId,
+ provider: provider.id,
+ providerScope: provider.scope,
+ purchaseId: owner.id,
+ userId: owner.userId,
+ })
+ .returning();
+
+ // A first payment that never completed ends the purchase as failed.
+ if (
+ state.status === "incomplete_expired" &&
+ (owner.paymentStatus === "awaiting_payment" ||
+ owner.paymentStatus === "processing")
+ ) {
+ await tx
+ .update(core_payments_purchases)
+ .set({ nextCheckAt: null, paymentStatus: "failed" })
+ .where(eq(core_payments_purchases.id, owner.id));
+ }
+
+ changed = changedForPlugin(before, stored);
+
+ if (changed) {
+ await scheduleFulfillment(c, tx, {
+ effect: FULFILLMENT_EFFECTS.subscription,
+ purchaseId: owner.id,
+ rerun: true,
+ });
+ }
+
+ return stored;
+ });
+
+ if (duplicateOf) {
+ const message = `A second subscription (${state.id}) was created for this purchase, which already has ${duplicateOf}. Cancel and refund the extra one in the provider.`;
+
+ await db
+ .update(core_payments_purchases)
+ .set({ lastError: message })
+ .where(eq(core_payments_purchases.id, purchaseId));
+
+ throw new PaymentsPermanentError(message);
+ }
+
+ if (changed) await afterSubscriptionChange(c, row);
+
+ // An `invoice.paid` webhook that never arrived must not cost the subscriber
+ // their access, so the latest invoice is checked on every sync.
+ if (state.latestInvoiceId) {
+ const [known] = await db
+ .select({ status: core_payments_invoices.status })
+ .from(core_payments_invoices)
+ .where(
+ and(
+ eq(core_payments_invoices.provider, provider.id),
+ eq(core_payments_invoices.providerScope, provider.scope),
+ eq(core_payments_invoices.externalId, state.latestInvoiceId),
+ ),
+ );
+
+ if (known?.status !== "paid") {
+ return (
+ (await syncInvoice(c, provider, state.latestInvoiceId, "updated", {
+ subscription: row,
+ })) ?? row
+ );
+ }
+ }
+
+ return row;
+};
+
+const invoiceStatusFor = (
+ invoice: ProviderInvoice,
+ signal: "action_required" | "paid" | "payment_failed" | "updated",
+ current: InvoiceStatus | undefined,
+): InvoiceStatus => {
+ if (current === "paid" || invoice.status !== "open") {
+ return current === "paid" ? "paid" : invoice.status;
+ }
+
+ if (signal === "payment_failed") return "payment_failed";
+ if (signal === "action_required") return "action_required";
+
+ return current ?? "open";
+};
+
+const laterOf = (a: Date | null, b: Date | null): Date | null => {
+ if (!a) return b;
+ if (!b) return a;
+
+ return a > b ? a : b;
+};
+
+/**
+ * Stores one recurring charge and, when it is paid, moves the subscription's
+ * paid-through boundary to the end of the period it paid for. The boundary is
+ * the latest *verified* period end, never "now + one month": retrying the same
+ * invoice changes nothing, and a failed renewal leaves the paid period alone.
+ */
+export const syncInvoice = async (
+ c: PaymentsContext,
+ provider: PaymentProvider,
+ invoiceId: string,
+ signal: "action_required" | "paid" | "payment_failed" | "updated",
+ { subscription: known }: { subscription?: SubscriptionRow } = {},
+): Promise => {
+ const api = requireSubscriptions(provider);
+ const invoice = await api.retrieveInvoice(invoiceId);
+
+ // An invoice for something VitNode never sold - not ours to record.
+ if (!invoice.subscriptionId) return undefined;
+
+ const db = c.get("db");
+ let subscription = known;
+
+ if (!subscription) {
+ [subscription] = await db
+ .select()
+ .from(core_payments_subscriptions)
+ .where(
+ and(
+ eq(core_payments_subscriptions.provider, provider.id),
+ eq(core_payments_subscriptions.providerScope, provider.scope),
+ eq(core_payments_subscriptions.externalId, invoice.subscriptionId),
+ ),
+ );
+ }
+
+ // The invoice can arrive before the checkout that created the
+ // subscription was processed. Syncing the subscription first records it.
+ subscription ??= await syncSubscription(c, provider, invoice.subscriptionId);
+
+ let changed = false;
+ let purchasePaid: null | PurchaseRow = null;
+
+ const row = await db.transaction(async tx => {
+ // Same order as every other writer: purchase, then subscription.
+ await tx
+ .select({ id: core_payments_purchases.id })
+ .from(core_payments_purchases)
+ .where(eq(core_payments_purchases.id, subscription.purchaseId))
+ .for("update");
+
+ const [locked] = await tx
+ .select()
+ .from(core_payments_subscriptions)
+ .where(eq(core_payments_subscriptions.id, subscription.id))
+ .for("update");
+
+ const [existing] = await tx
+ .select()
+ .from(core_payments_invoices)
+ .where(
+ and(
+ eq(core_payments_invoices.provider, provider.id),
+ eq(core_payments_invoices.providerScope, provider.scope),
+ eq(core_payments_invoices.externalId, invoice.id),
+ ),
+ );
+
+ const status = invoiceStatusFor(invoice, signal, existing?.status);
+ const values = {
+ amountDue: assertMinorUnits(invoice.amountDue),
+ amountPaid: assertMinorUnits(invoice.amountPaid),
+ billingReason: invoice.billingReason,
+ currency: invoice.currency,
+ externalPaymentId:
+ invoice.paymentId ?? existing?.externalPaymentId ?? null,
+ paidAt: invoice.paidAt ?? existing?.paidAt ?? null,
+ periodEnd: invoice.periodEnd,
+ periodStart: invoice.periodStart,
+ providerStatus: invoice.providerStatus,
+ status,
+ };
+
+ if (existing) {
+ await tx
+ .update(core_payments_invoices)
+ .set(values)
+ .where(eq(core_payments_invoices.id, existing.id));
+ } else {
+ await tx.insert(core_payments_invoices).values({
+ ...values,
+ externalId: invoice.id,
+ provider: provider.id,
+ providerScope: provider.scope,
+ subscriptionId: locked.id,
+ });
+ }
+
+ const paidThrough =
+ status === "paid"
+ ? laterOf(locked.paidThrough, invoice.periodEnd)
+ : locked.paidThrough;
+
+ const [stored] = await tx
+ .update(core_payments_subscriptions)
+ .set({ paidThrough })
+ .where(eq(core_payments_subscriptions.id, locked.id))
+ .returning();
+
+ // The first paid invoice is the subscription purchase's payment.
+ if (status === "paid") {
+ const [purchase] = await tx
+ .update(core_payments_purchases)
+ .set({
+ nextCheckAt: null,
+ paidAt: invoice.paidAt ?? new Date(),
+ paymentStatus: "paid",
+ })
+ .where(
+ and(
+ eq(core_payments_purchases.id, locked.purchaseId),
+ notInArray(core_payments_purchases.paymentStatus, ["paid"]),
+ ),
+ )
+ .returning();
+
+ purchasePaid = purchase ?? null;
+ }
+
+ changed = changedForPlugin(locked, stored);
+
+ if (changed) {
+ await scheduleFulfillment(c, tx, {
+ effect: FULFILLMENT_EFFECTS.subscription,
+ purchaseId: locked.purchaseId,
+ rerun: true,
+ });
+ }
+
+ return stored;
+ });
+
+ if (purchasePaid) {
+ const paid: PurchaseRow = purchasePaid;
+ await emitPaymentsEvent(c, "payments.purchase.paid", {
+ amount: paid.amount,
+ currency: paid.currency,
+ offerId: paid.offerId,
+ pluginId: paid.pluginId,
+ purchaseId: paid.publicId,
+ userId: paid.userId,
+ });
+ }
+
+ if (changed) await afterSubscriptionChange(c, row);
+
+ return row;
+};
+
+const refundStatusOf = (refunded: number, paid: number): RefundStatus => {
+ if (refunded <= 0) return "none";
+
+ return refunded >= paid ? "full" : "partial";
+};
+
+const disputeStatusOf = (payment: ProviderPayment) => {
+ if (payment.disputes.length === 0) return null;
+ if (payment.disputes.some(dispute => dispute.status === "open"))
+ return "open";
+ if (payment.disputes.some(dispute => dispute.status === "lost"))
+ return "lost";
+
+ return "won";
+};
+
+/**
+ * Records refunds and disputes for one payment against the purchase or the
+ * recurring invoice it paid for. The original paid amount is never changed.
+ * A one-time purchase gets one plugin call per refund that succeeded - the
+ * plugin decides what a refund means for access.
+ */
+export const syncPayment = async (
+ c: PaymentsContext,
+ provider: PaymentProvider,
+ paymentId: string,
+): Promise => {
+ const facts = await provider.payments.retrieve(paymentId);
+ const db = c.get("db");
+ const [purchase] = await db
+ .select()
+ .from(core_payments_purchases)
+ .where(
+ and(
+ eq(core_payments_purchases.provider, provider.id),
+ eq(core_payments_purchases.providerScope, provider.scope),
+ eq(core_payments_purchases.externalPaymentId, paymentId),
+ ),
+ );
+
+ const [invoice] = purchase
+ ? []
+ : await db
+ .select()
+ .from(core_payments_invoices)
+ .where(
+ and(
+ eq(core_payments_invoices.provider, provider.id),
+ eq(core_payments_invoices.providerScope, provider.scope),
+ eq(core_payments_invoices.externalPaymentId, paymentId),
+ ),
+ );
+
+ if (!purchase && !invoice) {
+ // Usually the refund raced the payment's own confirmation. Retried by the
+ // queue; if it never resolves, it is not a payment this site took.
+ throw new Error(
+ `Payment ${paymentId} is not linked to a purchase or invoice yet.`,
+ );
+ }
+
+ const disputeStatus = disputeStatusOf(facts);
+ const newRefunds: { amount: number; id: string }[] = [];
+ let refundedPurchase: null | PurchaseRow = null;
+
+ await db.transaction(async tx => {
+ const [current] = purchase
+ ? await tx
+ .select({ refundedAmount: core_payments_purchases.refundedAmount })
+ .from(core_payments_purchases)
+ .where(eq(core_payments_purchases.id, purchase.id))
+ .for("update")
+ : await tx
+ .select({ refundedAmount: core_payments_invoices.refundedAmount })
+ .from(core_payments_invoices)
+ .where(eq(core_payments_invoices.id, invoice.id))
+ .for("update");
+ let refundFailed = false;
+
+ const owner = purchase
+ ? { invoiceId: null, purchaseId: purchase.id }
+ : { invoiceId: invoice.id, purchaseId: null };
+
+ const adjustments = [
+ ...facts.refunds.map(refund => ({
+ amount: refund.amount,
+ id: refund.id,
+ kind: "refund" as const,
+ reason: null,
+ status: refund.status,
+ })),
+ ...facts.disputes.map(dispute => ({
+ amount: dispute.amount,
+ id: dispute.id,
+ kind: "dispute" as const,
+ reason: dispute.reason,
+ status: dispute.providerStatus,
+ })),
+ ];
+
+ for (const adjustment of adjustments) {
+ const [before] = await tx
+ .select({ status: core_payments_adjustments.status })
+ .from(core_payments_adjustments)
+ .where(
+ and(
+ eq(core_payments_adjustments.provider, provider.id),
+ eq(core_payments_adjustments.providerScope, provider.scope),
+ eq(core_payments_adjustments.kind, adjustment.kind),
+ eq(core_payments_adjustments.externalId, adjustment.id),
+ ),
+ );
+
+ // A stale read can still say "pending" after the refund succeeded.
+ const status =
+ adjustment.kind === "refund" &&
+ before?.status === "succeeded" &&
+ (adjustment.status === "pending" ||
+ adjustment.status === "requires_action")
+ ? before.status
+ : adjustment.status;
+
+ if (
+ adjustment.kind === "refund" &&
+ (status === "failed" || status === "canceled") &&
+ before?.status !== status
+ ) {
+ refundFailed = true;
+ }
+
+ await tx
+ .insert(core_payments_adjustments)
+ .values({
+ ...owner,
+ amount: assertMinorUnits(adjustment.amount),
+ currency: facts.currency,
+ externalId: adjustment.id,
+ kind: adjustment.kind,
+ provider: provider.id,
+ providerScope: provider.scope,
+ reason: adjustment.reason,
+ status,
+ })
+ .onConflictDoUpdate({
+ set: { reason: adjustment.reason, status },
+ target: [
+ core_payments_adjustments.provider,
+ core_payments_adjustments.providerScope,
+ core_payments_adjustments.kind,
+ core_payments_adjustments.externalId,
+ ],
+ });
+
+ if (
+ adjustment.kind === "refund" &&
+ adjustment.status === "succeeded" &&
+ before?.status !== "succeeded"
+ ) {
+ newRefunds.push({ amount: adjustment.amount, id: adjustment.id });
+ }
+ }
+
+ // Only a refund that just failed may lower the total; otherwise a stale
+ // read never shrinks it.
+ const reported = assertMinorUnits(facts.amountRefunded);
+ const refundedAmount = refundFailed
+ ? reported
+ : Math.max(reported, current.refundedAmount);
+
+ if (purchase) {
+ const [updated] = await tx
+ .update(core_payments_purchases)
+ .set({
+ disputeStatus,
+ refundedAmount,
+ refundStatus: refundStatusOf(refundedAmount, purchase.amount),
+ })
+ .where(eq(core_payments_purchases.id, purchase.id))
+ .returning();
+
+ if (purchase.mode === "one_time") {
+ for (const refund of newRefunds) {
+ await scheduleFulfillment(c, tx, {
+ effect: FULFILLMENT_EFFECTS.refund(refund.id),
+ payload: { amount: refund.amount },
+ purchaseId: purchase.id,
+ });
+ }
+ }
+
+ if (updated.refundedAmount !== purchase.refundedAmount) {
+ refundedPurchase = updated;
+ }
+ } else {
+ await tx
+ .update(core_payments_invoices)
+ .set({
+ disputeStatus,
+ refundedAmount,
+ refundStatus: refundStatusOf(refundedAmount, invoice.amountPaid),
+ })
+ .where(eq(core_payments_invoices.id, invoice.id));
+ }
+ });
+
+ if (refundedPurchase) {
+ const refunded: PurchaseRow = refundedPurchase;
+ await emitPaymentsEvent(c, "payments.purchase.refunded", {
+ currency: refunded.currency,
+ purchaseId: refunded.publicId,
+ refundedAmount: refunded.refundedAmount,
+ refundStatus: refunded.refundStatus,
+ });
+ }
+};
+
+/**
+ * Asks the provider about a purchase that is still open and applies the
+ * answer. Used by reconciliation and by the buyer's "check again" after
+ * returning from checkout - the redirect itself proves nothing.
+ */
+export const refreshPurchase = async (
+ c: PaymentsContext,
+ provider: PaymentProvider,
+ purchase: PurchaseRow,
+): Promise => {
+ if (
+ purchase.paymentStatus !== "awaiting_payment" &&
+ purchase.paymentStatus !== "processing"
+ ) {
+ return purchase;
+ }
+
+ const [checkout] = await c
+ .get("db")
+ .select()
+ .from(core_payments_checkouts)
+ .where(
+ and(
+ eq(core_payments_checkouts.purchaseId, purchase.id),
+ inArray(core_payments_checkouts.status, ["open", "complete"]),
+ sql`${core_payments_checkouts.externalId} IS NOT NULL`,
+ ),
+ )
+ .orderBy(sql`${core_payments_checkouts.id} DESC`)
+ .limit(1);
+
+ if (!checkout?.externalId) return purchase;
+
+ const state = await provider.checkout.retrieve(checkout.externalId);
+ const applied = await applyCheckoutState(c, provider, state);
+
+ if (applied.subscriptionId) {
+ await syncSubscription(c, provider, applied.subscriptionId, {
+ checkoutPurchase: applied.purchase,
+ });
+
+ const [fresh] = await c
+ .get("db")
+ .select()
+ .from(core_payments_purchases)
+ .where(eq(core_payments_purchases.id, purchase.id));
+
+ return fresh;
+ }
+
+ return applied.purchase;
+};
diff --git a/packages/vitnode/src/api/models/payments/webhooks.integration.test.ts b/packages/vitnode/src/api/models/payments/webhooks.integration.test.ts
new file mode 100644
index 0000000000..fbb007f4bf
--- /dev/null
+++ b/packages/vitnode/src/api/models/payments/webhooks.integration.test.ts
@@ -0,0 +1,575 @@
+import { OpenAPIHono } from "@hono/zod-openapi";
+import { eq, inArray } from "drizzle-orm";
+import { afterAll, beforeAll, describe, expect, it } from "vitest";
+
+// @vitest-environment node
+import type { EnvVitNode } from "@/api/middlewares/global.middleware";
+import type { ProviderWebhookEvent } from "@/payments/provider";
+
+import { webhookRoute } from "@/api/modules/payments/routes/webhook.route";
+import {
+ core_payments_adjustments,
+ core_payments_fulfillments,
+ core_payments_purchases,
+ core_payments_webhook_events,
+} from "@/database/payments";
+import { core_queue } from "@/database/queue";
+import {
+ createPaymentsHarness,
+ must,
+ type PaymentsHarness,
+ TEST_PLUGIN_ID,
+} from "@/tests/payments";
+import { describePostgres } from "@/tests/postgres";
+
+import { startCheckout } from "./checkout";
+import { retryFulfillment } from "./fulfillment";
+import { refreshPurchase } from "./sync";
+import { acceptWebhookEvent, processWebhookEvent } from "./webhooks";
+
+let eventSeq = 0;
+
+describePostgres("webhook ingestion", () => {
+ let h: PaymentsHarness;
+ let app: OpenAPIHono;
+ let failDispatch = false;
+
+ beforeAll(async () => {
+ h = await createPaymentsHarness();
+ app = new OpenAPIHono();
+ app.use(
+ "*",
+ h.middleware({
+ queue: () =>
+ failDispatch
+ ? {
+ dispatch: async () =>
+ await Promise.reject(new Error("queue down")),
+ }
+ : undefined,
+ }),
+ );
+ app.openapi(webhookRoute.route, webhookRoute.handler);
+ });
+
+ afterAll(async () => {
+ await h?.database.drop();
+ });
+
+ /** Everything queued has run, including the route's own best-effort kick. */
+ const settle = async () => {
+ for (let round = 0; round < 50; round++) {
+ await h.drainQueue();
+ const busy = await h.database.db
+ .select({ id: core_queue.id })
+ .from(core_queue)
+ .where(inArray(core_queue.status, ["pending", "processing"]));
+ if (busy.length === 0) return;
+ await new Promise(resolve => setTimeout(resolve, 20));
+ }
+ };
+
+ const deliver = async (
+ event: Partial & Pick,
+ { signature = "valid" }: { signature?: string } = {},
+ ) => {
+ eventSeq += 1;
+ const full: ProviderWebhookEvent = {
+ id: `evt_${eventSeq}`,
+ scope: "test",
+ type: "checkout.session.completed",
+ ...event,
+ };
+ h.fake.nextWebhook(full);
+
+ const response = await app.request("/webhooks/fake", {
+ body: JSON.stringify({ id: full.id }),
+ headers: {
+ "content-type": "application/json",
+ "x-fake-signature": signature,
+ },
+ method: "POST",
+ });
+
+ return { event: full, response };
+ };
+
+ const paidPurchase = async (
+ outcome: "failed" | "paid" | "unpaid" = "paid",
+ ) => {
+ const user = await h.createUser();
+ const { purchase } = await startCheckout(h.c, user, {
+ currency: "PLN",
+ interval: null,
+ offerId: "lifetime",
+ pluginId: TEST_PLUGIN_ID,
+ });
+ const checkout = must(
+ [...h.fake.checkouts.values()].find(
+ item => item.reference === purchase.publicId,
+ ),
+ );
+ h.fake.complete(checkout.id, outcome);
+
+ return { checkoutId: checkout.id, purchase, user };
+ };
+
+ const purchaseRow = async (id: number) =>
+ (
+ await h.database.db
+ .select()
+ .from(core_payments_purchases)
+ .where(eq(core_payments_purchases.id, id))
+ )[0];
+
+ it("rejects an invalid signature and stores nothing", async () => {
+ const before = await h.database.db
+ .select()
+ .from(core_payments_webhook_events);
+ const { response } = await deliver(
+ { target: { id: "cs_x", kind: "checkout" } },
+ { signature: "forged" },
+ );
+
+ expect(response.status).toBe(400);
+ expect(
+ await h.database.db.select().from(core_payments_webhook_events),
+ ).toHaveLength(before.length);
+ });
+
+ it("answers 404 for a provider that is not configured", async () => {
+ const response = await app.request("/webhooks/paypal", {
+ body: "{}",
+ headers: { "content-type": "application/json" },
+ method: "POST",
+ });
+
+ expect(response.status).toBe(404);
+ });
+
+ it("refuses a body larger than any provider event", async () => {
+ const response = await app.request("/webhooks/fake", {
+ body: "x".repeat(600 * 1024),
+ headers: {
+ "content-type": "application/json",
+ "x-fake-signature": "valid",
+ },
+ method: "POST",
+ });
+
+ expect(response.status).toBe(413);
+ });
+
+ it("acknowledges an event type it does not use without storing it", async () => {
+ const { response } = await deliver({
+ target: null,
+ type: "customer.created",
+ });
+
+ expect(await response.json()).toEqual({ ignored: true, received: true });
+ });
+
+ it("stores the event and its queue job before acknowledging", async () => {
+ const { checkoutId } = await paidPurchase();
+ const { event, response } = await deliver({
+ target: { id: checkoutId, kind: "checkout" },
+ });
+
+ expect(response.status).toBe(200);
+ expect(await response.json()).toEqual({ received: true });
+
+ const [stored] = await h.database.db
+ .select()
+ .from(core_payments_webhook_events)
+ .where(eq(core_payments_webhook_events.externalId, event.id));
+ expect(stored.target).toEqual({ id: checkoutId, kind: "checkout" });
+ await settle();
+ });
+
+ it("does not acknowledge an event it could not store durably", async () => {
+ failDispatch = true;
+ try {
+ const { event, response } = await deliver({
+ target: { id: "cs_unstored", kind: "checkout" },
+ });
+
+ expect(response.status).toBe(500);
+ // The inbox insert rolled back together with the failed dispatch.
+ expect(
+ await h.database.db
+ .select()
+ .from(core_payments_webhook_events)
+ .where(eq(core_payments_webhook_events.externalId, event.id)),
+ ).toEqual([]);
+ } finally {
+ failDispatch = false;
+ }
+ });
+
+ it("acknowledges a repeated delivery without queueing it again", async () => {
+ const { checkoutId } = await paidPurchase();
+ const event: ProviderWebhookEvent = {
+ id: "evt_repeat",
+ scope: "test",
+ target: { id: checkoutId, kind: "checkout" },
+ type: "checkout.session.completed",
+ };
+
+ const first = await acceptWebhookEvent(h.c, h.fake.provider, {
+ ...event,
+ target: { id: checkoutId, kind: "checkout" },
+ });
+ h.fake.nextWebhook(event);
+ const repeated = await app.request("/webhooks/fake", {
+ body: "{}",
+ headers: {
+ "content-type": "application/json",
+ "x-fake-signature": "valid",
+ },
+ method: "POST",
+ });
+
+ expect(first.status).toBe("accepted");
+ expect(await repeated.json()).toEqual({ duplicate: true, received: true });
+ expect(
+ await h.database.db
+ .select()
+ .from(core_queue)
+ .where(eq(core_queue.name, "payments-process-event")),
+ ).toHaveLength(
+ (await h.database.db.select().from(core_payments_webhook_events)).length,
+ );
+ await settle();
+ });
+
+ it("grants once when two different events report the same payment", async () => {
+ const { checkoutId, purchase, user } = await paidPurchase();
+
+ await deliver({ target: { id: checkoutId, kind: "checkout" } });
+ await deliver({
+ target: { id: checkoutId, kind: "checkout" },
+ type: "checkout.session.async_payment_succeeded",
+ });
+ await settle();
+
+ expect(await h.access(user.id)).toHaveLength(1);
+ expect(
+ h.handlerCalls.filter(
+ call => call.kind === "paid" && call.purchaseId === purchase.publicId,
+ ),
+ ).toHaveLength(1);
+ expect((await purchaseRow(purchase.id)).fulfillmentStatus).toBe(
+ "fulfilled",
+ );
+ });
+
+ it("settles an asynchronous payment and ignores the older snapshot", async () => {
+ const { checkoutId, purchase, user } = await paidPurchase("unpaid");
+
+ await deliver({ target: { id: checkoutId, kind: "checkout" } });
+ await settle();
+ expect((await purchaseRow(purchase.id)).paymentStatus).toBe("processing");
+ expect(await h.access(user.id)).toEqual([]);
+
+ must(h.fake.checkouts.get(checkoutId)).paymentStatus = "paid";
+ await deliver({
+ target: { id: checkoutId, kind: "checkout" },
+ type: "checkout.session.async_payment_succeeded",
+ });
+ await settle();
+ expect((await purchaseRow(purchase.id)).paymentStatus).toBe("paid");
+
+ // The provider briefly reports the earlier state again - nothing moves back.
+ must(h.fake.checkouts.get(checkoutId)).paymentStatus = "unpaid";
+ await deliver({ target: { id: checkoutId, kind: "checkout" } });
+ await settle();
+ expect((await purchaseRow(purchase.id)).paymentStatus).toBe("paid");
+ expect(await h.access(user.id)).toHaveLength(1);
+ });
+
+ it("fails a declined asynchronous payment without granting", async () => {
+ const { checkoutId, purchase, user } = await paidPurchase("failed");
+
+ await deliver({
+ target: { id: checkoutId, kind: "checkout" },
+ type: "checkout.session.async_payment_failed",
+ });
+ await settle();
+
+ expect((await purchaseRow(purchase.id)).paymentStatus).toBe("failed");
+ expect(await h.access(user.id)).toEqual([]);
+ });
+
+ it("processes events in any order because it re-reads the provider", async () => {
+ const { checkoutId, purchase } = await paidPurchase();
+
+ // "expired" arrives last but the provider says the checkout completed.
+ await deliver({
+ target: { id: checkoutId, kind: "checkout" },
+ type: "checkout.session.async_payment_succeeded",
+ });
+ await deliver({
+ target: { id: checkoutId, kind: "checkout" },
+ type: "checkout.session.expired",
+ });
+ await settle();
+
+ expect((await purchaseRow(purchase.id)).paymentStatus).toBe("paid");
+ });
+
+ it("does not mark a purchase paid because the buyer was redirected back", async () => {
+ const user = await h.createUser();
+ const { purchase } = await startCheckout(h.c, user, {
+ currency: "USD",
+ interval: null,
+ offerId: "lifetime",
+ pluginId: TEST_PLUGIN_ID,
+ });
+
+ // The status refresh asks the provider; the checkout is still open.
+ const refreshed = await refreshPurchase(h.c, h.fake.provider, purchase);
+
+ expect(refreshed.paymentStatus).toBe("awaiting_payment");
+ });
+
+ it("records an event for an unknown checkout as a permanent failure", async () => {
+ const event = await acceptWebhookEvent(h.c, h.fake.provider, {
+ id: "evt_unknown",
+ scope: "test",
+ target: { id: "cs_missing", kind: "checkout" },
+ type: "checkout.session.completed",
+ });
+ if (event.status !== "accepted") throw new Error("expected accepted");
+
+ // Not found at the provider - retrying cannot help.
+ await processWebhookEvent(h.c, event.eventId);
+ const [stored] = await h.database.db
+ .select()
+ .from(core_payments_webhook_events)
+ .where(eq(core_payments_webhook_events.id, event.eventId));
+
+ expect(stored.status).toBe("failed");
+ expect(stored.lastError).toMatch(/No such checkout/);
+ });
+});
+
+describePostgres("fulfillment", () => {
+ let h: PaymentsHarness;
+
+ beforeAll(async () => {
+ h = await createPaymentsHarness();
+ });
+
+ afterAll(async () => {
+ await h?.database.drop();
+ });
+
+ const buy = async () => {
+ const user = await h.createUser();
+ const { purchase } = await startCheckout(h.c, user, {
+ currency: "PLN",
+ interval: null,
+ offerId: "lifetime",
+ pluginId: TEST_PLUGIN_ID,
+ });
+ const checkout = must(
+ [...h.fake.checkouts.values()].find(
+ item => item.reference === purchase.publicId,
+ ),
+ );
+ h.fake.complete(checkout.id);
+
+ return { checkout, purchase, user };
+ };
+
+ const row = async (id: number) =>
+ (
+ await h.database.db
+ .select()
+ .from(core_payments_purchases)
+ .where(eq(core_payments_purchases.id, id))
+ )[0];
+
+ it("commits the paid state and the fulfillment job together", async () => {
+ const { purchase } = await buy();
+ const c = h.forkContext();
+ c.set("queue", {
+ dispatch: async () => await Promise.reject(new Error("queue down")),
+ } as never);
+
+ await expect(refreshPurchase(c, h.fake.provider, purchase)).rejects.toThrow(
+ "queue down",
+ );
+
+ // Neither half survived: a paid purchase without its job cannot exist.
+ expect((await row(purchase.id)).paymentStatus).toBe("awaiting_payment");
+ expect(
+ await h.database.db
+ .select()
+ .from(core_payments_fulfillments)
+ .where(eq(core_payments_fulfillments.purchaseId, purchase.id)),
+ ).toEqual([]);
+ });
+
+ it("keeps a payment paid when its handler fails, and grants once on retry", async () => {
+ const { purchase, user } = await buy();
+ h.setHandlerFailure(new Error("Plugin database is read-only"));
+
+ await refreshPurchase(h.c, h.fake.provider, purchase);
+ await h.drainQueue();
+
+ const failed = await row(purchase.id);
+ expect(failed.paymentStatus).toBe("paid");
+ expect(failed.fulfillmentStatus).toBe("failed");
+ // The handler's insert rolled back with its failure.
+ expect(await h.access(user.id)).toEqual([]);
+
+ h.setHandlerFailure(null);
+ const [fulfillment] = await h.database.db
+ .select()
+ .from(core_payments_fulfillments)
+ .where(eq(core_payments_fulfillments.purchaseId, purchase.id));
+
+ expect(await retryFulfillment(h.c, fulfillment.id)).toBe(true);
+ // A second click while the first retry is queued changes nothing.
+ await h.drainQueue();
+ expect(await retryFulfillment(h.c, fulfillment.id)).toBe(false);
+ await h.drainQueue(new Date(Date.now() + 24 * 60 * 60 * 1000));
+
+ expect((await row(purchase.id)).fulfillmentStatus).toBe("fulfilled");
+ expect(await h.access(user.id)).toHaveLength(1);
+ });
+
+ it("makes a missing handler visible instead of granting something else", async () => {
+ const { purchase } = await buy();
+ await refreshPurchase(h.c, h.fake.provider, purchase);
+
+ const c = h.forkContext();
+ const core = c.get("core");
+ c.set("core", {
+ ...core,
+ payments: { ...core.payments, offers: new Map() },
+ });
+ const { runFulfillment } = await import("./fulfillment");
+ const [fulfillment] = await h.database.db
+ .select()
+ .from(core_payments_fulfillments)
+ .where(eq(core_payments_fulfillments.purchaseId, purchase.id));
+
+ expect(await runFulfillment(c, fulfillment.id)).toBe("failed");
+
+ const after = await row(purchase.id);
+ expect(after.paymentStatus).toBe("paid");
+ expect(after.fulfillmentStatus).toBe("failed");
+ expect(after.lastError).toMatch(/not registered/);
+ await h.drainQueue();
+ });
+
+ it("revokes the one-time grant once on a full refund, not on a partial one", async () => {
+ const { checkout, purchase, user } = await buy();
+ await refreshPurchase(h.c, h.fake.provider, purchase);
+ await h.drainQueue();
+ const paymentId = `pi_${checkout.id}`;
+ const { syncPayment } = await import("./sync");
+
+ h.fake.refund(paymentId, 900);
+ await syncPayment(h.c, h.fake.provider, paymentId);
+ await h.drainQueue();
+
+ const partial = await row(purchase.id);
+ expect(partial).toMatchObject({
+ amount: 1900,
+ refundedAmount: 900,
+ refundStatus: "partial",
+ });
+ expect(await h.access(user.id)).toHaveLength(1);
+
+ h.fake.refund(paymentId, 1000);
+ await syncPayment(h.c, h.fake.provider, paymentId);
+ // The same facts reported again do not call the plugin again.
+ await syncPayment(h.c, h.fake.provider, paymentId);
+ await h.drainQueue();
+
+ expect(await row(purchase.id)).toMatchObject({
+ amount: 1900,
+ paymentStatus: "paid",
+ refundedAmount: 1900,
+ refundStatus: "full",
+ });
+ expect(await h.access(user.id)).toEqual([]);
+ expect(
+ h.handlerCalls.filter(
+ call =>
+ call.kind === "refunded" && call.purchaseId === purchase.publicId,
+ ),
+ ).toHaveLength(2);
+ });
+
+ it("does not count a pending refund as refunded", async () => {
+ const { checkout, purchase, user } = await buy();
+ await refreshPurchase(h.c, h.fake.provider, purchase);
+ await h.drainQueue();
+ const paymentId = `pi_${checkout.id}`;
+ const { syncPayment } = await import("./sync");
+
+ h.fake.refund(paymentId, 1900, "pending");
+ await syncPayment(h.c, h.fake.provider, paymentId);
+ await h.drainQueue();
+
+ expect((await row(purchase.id)).refundStatus).toBe("none");
+ expect(await h.access(user.id)).toHaveLength(1);
+ });
+
+ it("never moves a succeeded refund back from a stale read, but records one that failed", async () => {
+ const { checkout, purchase } = await buy();
+ await refreshPurchase(h.c, h.fake.provider, purchase);
+ await h.drainQueue();
+ const paymentId = `pi_${checkout.id}`;
+ const { syncPayment } = await import("./sync");
+ const refundId = h.fake.refund(paymentId, 900);
+ await syncPayment(h.c, h.fake.provider, paymentId);
+ await h.drainQueue();
+ const payment = must(h.fake.payments.get(paymentId));
+ const refundCalls = () =>
+ h.handlerCalls.filter(
+ call =>
+ call.kind === "refunded" && call.purchaseId === purchase.publicId,
+ ).length;
+ const adjustment = async () =>
+ must(
+ (
+ await h.database.db
+ .select()
+ .from(core_payments_adjustments)
+ .where(eq(core_payments_adjustments.externalId, refundId))
+ )[0],
+ );
+
+ payment.refunds[0].status = "pending";
+ payment.amountRefunded = 0;
+ await syncPayment(h.c, h.fake.provider, paymentId);
+ await h.drainQueue();
+
+ expect((await adjustment()).status).toBe("succeeded");
+ expect(await row(purchase.id)).toMatchObject({
+ refundedAmount: 900,
+ refundStatus: "partial",
+ });
+ expect(refundCalls()).toBe(1);
+
+ payment.refunds[0].status = "failed";
+ await syncPayment(h.c, h.fake.provider, paymentId);
+
+ expect((await adjustment()).status).toBe("failed");
+ expect(await row(purchase.id)).toMatchObject({
+ refundedAmount: 0,
+ refundStatus: "none",
+ });
+ });
+});
+
+describe("webhook route contract", () => {
+ it("is mounted without a request body schema, so the raw bytes reach the provider", () => {
+ expect(webhookRoute.route.request).not.toHaveProperty("body");
+ });
+});
diff --git a/packages/vitnode/src/api/models/payments/webhooks.ts b/packages/vitnode/src/api/models/payments/webhooks.ts
new file mode 100644
index 0000000000..8197442970
--- /dev/null
+++ b/packages/vitnode/src/api/models/payments/webhooks.ts
@@ -0,0 +1,205 @@
+import { and, eq, inArray, sql } from "drizzle-orm";
+
+import type {
+ PaymentProvider,
+ ProviderWebhookEvent,
+ ProviderWebhookTarget,
+} from "@/payments/provider";
+
+import { core_payments_webhook_events } from "@/database/payments";
+
+import type { PaymentsContext } from "./shared";
+
+import {
+ describePaymentsError,
+ isPermanentPaymentsError,
+ PaymentsPermanentError,
+ providerForRow,
+} from "./shared";
+import {
+ applyCheckoutState,
+ syncInvoice,
+ syncPayment,
+ syncSubscription,
+} from "./sync";
+
+export const PROCESS_EVENT_TASK = "payments-process-event";
+
+export type WebhookAcceptance =
+ | { eventId: number; queueId: null | number; status: "accepted" }
+ | { status: "duplicate" };
+
+/**
+ * Stores a verified event and queues its processing in one transaction. Only
+ * when this returns may the provider be told "received": if the insert or the
+ * dispatch fails, the error propagates, the provider gets a 5xx and delivers
+ * the event again.
+ *
+ * The provider's event id is unique per provider and environment, so a
+ * repeated delivery finds the existing row and queues nothing.
+ */
+export const acceptWebhookEvent = async (
+ c: PaymentsContext,
+ provider: PaymentProvider,
+ event: ProviderWebhookEvent & { target: ProviderWebhookTarget },
+): Promise =>
+ await c.get("db").transaction(async tx => {
+ const [inserted] = await tx
+ .insert(core_payments_webhook_events)
+ .values({
+ externalId: event.id,
+ provider: provider.id,
+ providerScope: event.scope,
+ target: { ...event.target },
+ type: event.type,
+ })
+ .onConflictDoNothing()
+ .returning({ id: core_payments_webhook_events.id });
+
+ if (!inserted) return { status: "duplicate" as const };
+
+ const { id: queueId } = await c.get("queue").dispatch({
+ maxAttempts: 8,
+ name: PROCESS_EVENT_TASK,
+ payload: { eventId: inserted.id },
+ pluginId: "@vitnode/core",
+ tx,
+ });
+
+ return { eventId: inserted.id, queueId, status: "accepted" as const };
+ });
+
+const processTarget = async (
+ c: PaymentsContext,
+ provider: PaymentProvider,
+ target: ProviderWebhookTarget,
+): Promise => {
+ switch (target.kind) {
+ case "checkout": {
+ const checkout = await provider.checkout.retrieve(target.id);
+ const applied = await applyCheckoutState(c, provider, checkout);
+
+ if (applied.subscriptionId) {
+ await syncSubscription(c, provider, applied.subscriptionId, {
+ checkoutPurchase: applied.purchase,
+ });
+ }
+
+ return;
+ }
+ case "invoice":
+ await syncInvoice(c, provider, target.id, target.signal);
+
+ return;
+ case "payment":
+ await syncPayment(c, provider, target.id);
+
+ return;
+ case "subscription":
+ await syncSubscription(c, provider, target.id);
+ }
+};
+
+const parseTarget = (raw: Record): ProviderWebhookTarget => {
+ switch (raw.kind) {
+ case "checkout":
+ case "payment":
+ case "subscription":
+ return { id: raw.id, kind: raw.kind };
+ case "invoice":
+ return {
+ id: raw.id,
+ kind: "invoice",
+ signal:
+ raw.signal === "paid" ||
+ raw.signal === "payment_failed" ||
+ raw.signal === "action_required"
+ ? raw.signal
+ : "updated",
+ };
+ default:
+ throw new PaymentsPermanentError(`Unknown webhook target "${raw.kind}".`);
+ }
+};
+
+/**
+ * Processes one stored event: re-reads the object from the provider and
+ * applies it. A retryable failure is recorded and rethrown so the queue tries
+ * again with backoff; a permanent one is recorded and kept for the AdminCP.
+ */
+export const processWebhookEvent = async (
+ c: PaymentsContext,
+ eventId: number,
+): Promise => {
+ const db = c.get("db");
+ const [event] = await db
+ .select()
+ .from(core_payments_webhook_events)
+ .where(eq(core_payments_webhook_events.id, eventId));
+
+ if (!event || event.status === "processed") return;
+
+ try {
+ const provider = providerForRow(c, event);
+ await processTarget(c, provider, parseTarget(event.target));
+
+ await db
+ .update(core_payments_webhook_events)
+ .set({
+ attempts: sql`${core_payments_webhook_events.attempts} + 1`,
+ lastError: null,
+ processedAt: new Date(),
+ status: "processed",
+ })
+ .where(eq(core_payments_webhook_events.id, event.id));
+ } catch (error) {
+ const message = describePaymentsError(error);
+
+ await db
+ .update(core_payments_webhook_events)
+ .set({
+ attempts: sql`${core_payments_webhook_events.attempts} + 1`,
+ lastError: message,
+ status: "failed",
+ })
+ .where(eq(core_payments_webhook_events.id, event.id));
+
+ await c
+ .get("log")
+ .error(
+ `[Payments] Webhook ${event.type} (${event.externalId}) failed: ${message}`,
+ );
+
+ if (!isPermanentPaymentsError(error)) throw error;
+ }
+};
+
+/** Queues a failed event again. Processing is idempotent, so this is too. */
+export const retryWebhookEvent = async (
+ c: PaymentsContext,
+ eventId: number,
+): Promise =>
+ await c.get("db").transaction(async tx => {
+ const [row] = await tx
+ .update(core_payments_webhook_events)
+ .set({ status: "pending" })
+ .where(
+ and(
+ eq(core_payments_webhook_events.id, eventId),
+ inArray(core_payments_webhook_events.status, ["failed"]),
+ ),
+ )
+ .returning({ id: core_payments_webhook_events.id });
+
+ if (!row) return false;
+
+ await c.get("queue").dispatch({
+ maxAttempts: 8,
+ name: PROCESS_EVENT_TASK,
+ payload: { eventId },
+ pluginId: "@vitnode/core",
+ tx,
+ });
+
+ return true;
+ });
diff --git a/packages/vitnode/src/api/modules/admin/admin.module.ts b/packages/vitnode/src/api/modules/admin/admin.module.ts
index b89d203a6c..842804b084 100644
--- a/packages/vitnode/src/api/modules/admin/admin.module.ts
+++ b/packages/vitnode/src/api/modules/admin/admin.module.ts
@@ -8,6 +8,7 @@ import { debugAdminModule } from "./debug/debug.admin.module";
import { filesAdminModule } from "./files/files.admin.module";
import { navigationAdminModule } from "./navigation/navigation.admin.module";
import { notificationsAdminModule } from "./notifications/notifications.admin.module";
+import { paymentsAdminModule } from "./payments/payments.admin.module";
import { rolesAdminModule } from "./roles/roles.admin.module";
import { sendNotificationRoute } from "./routes/notifications.route";
import { sessionAdminRoute } from "./routes/session.route";
@@ -29,6 +30,7 @@ export const adminModule = buildModule({
dashboardAdminModule,
navigationAdminModule,
notificationsAdminModule,
+ paymentsAdminModule,
],
cronJobs: [],
});
diff --git a/packages/vitnode/src/api/modules/admin/payments/filters.ts b/packages/vitnode/src/api/modules/admin/payments/filters.ts
new file mode 100644
index 0000000000..3685906444
--- /dev/null
+++ b/packages/vitnode/src/api/modules/admin/payments/filters.ts
@@ -0,0 +1,55 @@
+import type { SQL } from "drizzle-orm";
+import type { PgColumn } from "drizzle-orm/pg-core";
+
+import { and, gte, inArray } from "drizzle-orm";
+import { z } from "zod";
+
+/** Relative windows the AdminCP filters by - days back from now. */
+export const PAYMENT_PERIODS = {
+ "7d": 7,
+ "30d": 30,
+ "90d": 90,
+ "365d": 365,
+} as const;
+export type PaymentPeriod = keyof typeof PAYMENT_PERIODS;
+
+/** Comma-separated values, each one checked; anything else is dropped. */
+export const commaList = (
+ raw: string | undefined,
+ accept: (value: string) => value is T,
+): T[] => [...new Set((raw ?? "").split(",").filter(accept))];
+
+export const zodPaymentsAdminFilters = z.object({
+ currency: z.string().max(200).optional(),
+ period: z.string().max(50).optional(),
+ provider: z.string().max(200).optional(),
+});
+
+const isCurrency = (value: string): value is string => /^[A-Z]{3}$/.test(value);
+const isProvider = (value: string): value is string =>
+ /^[a-z0-9-]{1,32}$/.test(value);
+const isPeriod = (value: string): value is PaymentPeriod =>
+ value in PAYMENT_PERIODS;
+
+/** The shared currency / provider / period filters as one condition. */
+export const commonPaymentFilters = (
+ query: z.infer,
+ columns: { createdAt: PgColumn; currency: PgColumn; provider: PgColumn },
+ now = new Date(),
+): SQL | undefined => {
+ const currencies = commaList(query.currency, isCurrency);
+ const providers = commaList(query.provider, isProvider);
+ // Several windows selected means the widest of them.
+ const days = Math.max(
+ 0,
+ ...commaList(query.period, isPeriod).map(period => PAYMENT_PERIODS[period]),
+ );
+
+ return and(
+ currencies.length ? inArray(columns.currency, currencies) : undefined,
+ providers.length ? inArray(columns.provider, providers) : undefined,
+ days
+ ? gte(columns.createdAt, new Date(now.getTime() - days * 86_400_000))
+ : undefined,
+ );
+};
diff --git a/packages/vitnode/src/api/modules/admin/payments/payments.admin.module.ts b/packages/vitnode/src/api/modules/admin/payments/payments.admin.module.ts
new file mode 100644
index 0000000000..c2fc8a57eb
--- /dev/null
+++ b/packages/vitnode/src/api/modules/admin/payments/payments.admin.module.ts
@@ -0,0 +1,28 @@
+import { buildModule } from "@/api/lib/module";
+import { CONFIG_PLUGIN } from "@/config";
+
+import { listWebhookEventsAdminRoute } from "./routes/events.route";
+import { paymentsOverviewAdminRoute } from "./routes/overview.route";
+import {
+ listPurchasesAdminRoute,
+ showPurchaseAdminRoute,
+} from "./routes/purchases.route";
+import {
+ retryFulfillmentAdminRoute,
+ retryWebhookEventAdminRoute,
+} from "./routes/retry.route";
+import { listSubscriptionsAdminRoute } from "./routes/subscriptions.route";
+
+export const paymentsAdminModule = buildModule({
+ pluginId: CONFIG_PLUGIN.pluginId,
+ name: "payments",
+ routes: [
+ paymentsOverviewAdminRoute,
+ listPurchasesAdminRoute,
+ showPurchaseAdminRoute,
+ listSubscriptionsAdminRoute,
+ listWebhookEventsAdminRoute,
+ retryFulfillmentAdminRoute,
+ retryWebhookEventAdminRoute,
+ ],
+});
diff --git a/packages/vitnode/src/api/modules/admin/payments/routes/events.route.ts b/packages/vitnode/src/api/modules/admin/payments/routes/events.route.ts
new file mode 100644
index 0000000000..86f87c3007
--- /dev/null
+++ b/packages/vitnode/src/api/modules/admin/payments/routes/events.route.ts
@@ -0,0 +1,112 @@
+import { getColumns, inArray } from "drizzle-orm";
+import { z } from "zod";
+
+import { buildRoute } from "@/api/lib/route";
+import {
+ withPagination,
+ zodPaginationPageInfo,
+ zodPaginationQuery,
+} from "@/api/lib/with-pagination";
+import { CONFIG_PLUGIN } from "@/config";
+import { core_payments_webhook_events } from "@/database/payments";
+
+import { commaList } from "../filters";
+
+const EVENT_STATUSES = ["pending", "processed", "failed"] as const;
+type EventStatus = (typeof EVENT_STATUSES)[number];
+const isStatus = (value: string): value is EventStatus =>
+ (EVENT_STATUSES as readonly string[]).includes(value);
+
+export const listWebhookEventsAdminRoute = buildRoute({
+ pluginId: CONFIG_PLUGIN.pluginId,
+ adminStaffPermission: { module: "payments", permission: "can_view" },
+ route: {
+ method: "get",
+ description:
+ "The webhook inbox: what each provider event was about and whether processing it worked. Raw payloads are never stored.",
+ path: "/events",
+ request: {
+ query: zodPaginationQuery.extend({
+ order: z.enum(["asc", "desc"]).optional(),
+ orderBy: z.enum(["receivedAt"]).optional(),
+ status: z.string().max(100).optional(),
+ }),
+ },
+ responses: {
+ 200: {
+ content: {
+ "application/json": {
+ schema: z.object({
+ edges: z.array(
+ z.object({
+ attempts: z.number(),
+ externalId: z.string(),
+ id: z.number(),
+ lastError: z.string().nullable(),
+ processedAt: z.date().nullable(),
+ provider: z.string(),
+ providerScope: z.string(),
+ receivedAt: z.date(),
+ status: z.enum(EVENT_STATUSES),
+ targetId: z.string(),
+ targetKind: z.string(),
+ type: z.string(),
+ }),
+ ),
+ pageInfo: zodPaginationPageInfo,
+ }),
+ },
+ },
+ description: "One page of webhook events",
+ },
+ },
+ },
+ handler: async c => {
+ const query = c.req.valid("query");
+ const statuses = commaList(query.status, isStatus);
+
+ const page = await withPagination({
+ c,
+ orderBy: {
+ column: core_payments_webhook_events.receivedAt,
+ order: query.order ?? "desc",
+ },
+ params: { query },
+ primaryCursor: core_payments_webhook_events.id,
+ query: async ({ cursorSelection, limit, offset, orderBy, where }) =>
+ await c
+ .get("db")
+ .select({
+ ...getColumns(core_payments_webhook_events),
+ ...cursorSelection,
+ })
+ .from(core_payments_webhook_events)
+ .where(where)
+ .orderBy(orderBy)
+ .limit(limit)
+ .offset(offset),
+ table: core_payments_webhook_events,
+ where: statuses.length
+ ? inArray(core_payments_webhook_events.status, statuses)
+ : undefined,
+ });
+
+ return c.json({
+ edges: page.edges.map(row => ({
+ attempts: row.attempts,
+ externalId: row.externalId,
+ id: row.id,
+ lastError: row.lastError,
+ processedAt: row.processedAt,
+ provider: row.provider,
+ providerScope: row.providerScope,
+ receivedAt: row.receivedAt,
+ status: row.status,
+ targetId: row.target.id ?? "",
+ targetKind: row.target.kind ?? "",
+ type: row.type,
+ })),
+ pageInfo: page.pageInfo,
+ });
+ },
+});
diff --git a/packages/vitnode/src/api/modules/admin/payments/routes/overview.route.ts b/packages/vitnode/src/api/modules/admin/payments/routes/overview.route.ts
new file mode 100644
index 0000000000..2d90dfb1fb
--- /dev/null
+++ b/packages/vitnode/src/api/modules/admin/payments/routes/overview.route.ts
@@ -0,0 +1,193 @@
+import { and, count, eq, inArray, lt, sql, sum } from "drizzle-orm";
+import { z } from "zod";
+
+import { buildRoute } from "@/api/lib/route";
+import { loadCronHealth } from "@/api/modules/cron/helpers/load-cron-health";
+import { CONFIG_PLUGIN } from "@/config";
+import {
+ core_payments_fulfillments,
+ core_payments_invoices,
+ core_payments_purchases,
+ core_payments_subscriptions,
+ core_payments_webhook_events,
+} from "@/database/payments";
+import { core_queue } from "@/database/queue";
+
+const QUEUE_LAG_MINUTES = 10;
+
+const toNumber = (value: null | number | string | undefined): number =>
+ value === null || value === undefined ? 0 : Number(value);
+
+export const paymentsOverviewAdminRoute = buildRoute({
+ pluginId: CONFIG_PLUGIN.pluginId,
+ adminStaffPermission: { module: "payments", permission: "can_view" },
+ route: {
+ method: "get",
+ description:
+ "Payments health: totals per currency (never mixed), work that needs attention, and whether the queue worker is running.",
+ path: "/overview",
+ responses: {
+ 200: {
+ content: {
+ "application/json": {
+ schema: z.object({
+ attention: z.object({
+ disputesOpen: z.number(),
+ eventsFailed: z.number(),
+ fulfillmentsFailed: z.number(),
+ fulfillmentsPending: z.number(),
+ purchasesProcessing: z.number(),
+ subscriptionsNeedingAttention: z.number(),
+ }),
+ enabled: z.boolean(),
+ providers: z.array(
+ z.object({
+ id: z.string(),
+ name: z.string(),
+ scope: z.string(),
+ }),
+ ),
+ totals: z.array(
+ z.object({
+ currency: z.string(),
+ oneTime: z.number(),
+ recurring: z.number(),
+ refunded: z.number(),
+ }),
+ ),
+ worker: z.object({
+ hasCronAdapter: z.boolean(),
+ lastRun: z.date().nullable(),
+ queueLagging: z.number(),
+ stale: z.boolean(),
+ }),
+ }),
+ },
+ },
+ description: "Payments overview",
+ },
+ },
+ },
+ handler: async c => {
+ const db = c.get("db");
+ const config = c.get("core").payments.config;
+ const now = Date.now();
+
+ const [oneTime, recurring, attention, health, lagging] = await Promise.all([
+ db
+ .select({
+ amount: sum(core_payments_purchases.amount),
+ currency: core_payments_purchases.currency,
+ refunded: sum(core_payments_purchases.refundedAmount),
+ })
+ .from(core_payments_purchases)
+ .where(
+ and(
+ eq(core_payments_purchases.mode, "one_time"),
+ eq(core_payments_purchases.paymentStatus, "paid"),
+ ),
+ )
+ .groupBy(core_payments_purchases.currency),
+ db
+ .select({
+ amount: sum(core_payments_invoices.amountPaid),
+ currency: core_payments_invoices.currency,
+ refunded: sum(core_payments_invoices.refundedAmount),
+ })
+ .from(core_payments_invoices)
+ .where(eq(core_payments_invoices.status, "paid"))
+ .groupBy(core_payments_invoices.currency),
+ Promise.all([
+ db
+ .select({ value: count() })
+ .from(core_payments_purchases)
+ .where(eq(core_payments_purchases.disputeStatus, "open")),
+ db
+ .select({ value: count() })
+ .from(core_payments_webhook_events)
+ .where(eq(core_payments_webhook_events.status, "failed")),
+ db
+ .select({ value: count() })
+ .from(core_payments_fulfillments)
+ .where(eq(core_payments_fulfillments.status, "failed")),
+ db
+ .select({ value: count() })
+ .from(core_payments_fulfillments)
+ .where(eq(core_payments_fulfillments.status, "pending")),
+ db
+ .select({ value: count() })
+ .from(core_payments_purchases)
+ .where(eq(core_payments_purchases.paymentStatus, "processing")),
+ db
+ .select({ value: count() })
+ .from(core_payments_subscriptions)
+ .where(
+ inArray(core_payments_subscriptions.status, [
+ "past_due",
+ "unpaid",
+ "paused",
+ "incomplete",
+ ]),
+ ),
+ ]),
+ loadCronHealth(db),
+ // Payment jobs nobody has picked up for a while: the clearest sign that
+ // no worker is ticking the queue on this deployment.
+ db
+ .select({ value: count() })
+ .from(core_queue)
+ .where(
+ and(
+ eq(core_queue.pluginId, "@vitnode/core"),
+ sql`${core_queue.name} LIKE 'payments-%'`,
+ eq(core_queue.status, "pending"),
+ lt(
+ core_queue.availableAt,
+ new Date(now - QUEUE_LAG_MINUTES * 60_000),
+ ),
+ ),
+ ),
+ ]);
+
+ const currencies = new Set([
+ ...oneTime.map(row => row.currency),
+ ...recurring.map(row => row.currency),
+ ]);
+ const [disputes, events, failed, pending, processing, subscriptions] =
+ attention.map(rows => rows[0]?.value ?? 0);
+
+ return c.json({
+ attention: {
+ disputesOpen: disputes,
+ eventsFailed: events,
+ fulfillmentsFailed: failed,
+ fulfillmentsPending: pending,
+ purchasesProcessing: processing,
+ subscriptionsNeedingAttention: subscriptions,
+ },
+ enabled: !!config,
+ providers: (config?.providers ?? []).map(provider => ({
+ id: provider.id,
+ name: provider.name,
+ scope: provider.scope,
+ })),
+ totals: [...currencies].sort().map(currency => {
+ const once = oneTime.find(row => row.currency === currency);
+ const repeat = recurring.find(row => row.currency === currency);
+
+ return {
+ currency,
+ oneTime: toNumber(once?.amount),
+ recurring: toNumber(repeat?.amount),
+ refunded: toNumber(once?.refunded) + toNumber(repeat?.refunded),
+ };
+ }),
+ worker: {
+ hasCronAdapter: c.get("core").hasCronAdapter,
+ lastRun: health.lastRun,
+ queueLagging: lagging[0]?.value ?? 0,
+ stale: health.stale,
+ },
+ });
+ },
+});
diff --git a/packages/vitnode/src/api/modules/admin/payments/routes/purchases.route.ts b/packages/vitnode/src/api/modules/admin/payments/routes/purchases.route.ts
new file mode 100644
index 0000000000..36ab140d43
--- /dev/null
+++ b/packages/vitnode/src/api/modules/admin/payments/routes/purchases.route.ts
@@ -0,0 +1,347 @@
+import { and, desc, eq, getColumns, inArray } from "drizzle-orm";
+import { HTTPException } from "hono/http-exception";
+import { z } from "zod";
+
+import { buildRoute } from "@/api/lib/route";
+import {
+ withPagination,
+ zodPaginationPageInfo,
+ zodPaginationQuery,
+} from "@/api/lib/with-pagination";
+import {
+ serializePurchase,
+ serializeSubscription,
+ zodPurchase,
+ zodSubscription,
+} from "@/api/modules/payments/schema";
+import { CONFIG_PLUGIN } from "@/config";
+import {
+ core_payments_adjustments,
+ core_payments_checkouts,
+ core_payments_fulfillments,
+ core_payments_invoices,
+ core_payments_purchases,
+ core_payments_subscriptions,
+} from "@/database/payments";
+import { core_users } from "@/database/users";
+import {
+ FULFILLMENT_STATUSES,
+ type FulfillmentStatus,
+ PURCHASE_PAYMENT_STATUSES,
+ type PurchasePaymentStatus,
+} from "@/payments/status";
+
+import {
+ commaList,
+ commonPaymentFilters,
+ zodPaymentsAdminFilters,
+} from "../filters";
+
+const zodAdminPurchase = zodPurchase.extend({
+ /** Internal row id - AdminCP tables key rows by it. */
+ id: z.number(),
+ lastError: z.string().nullable(),
+ publicId: z.string(),
+ providerScope: z.string(),
+ user: z.object({ id: z.number(), name: z.string() }).nullable(),
+});
+
+const isPaymentStatus = (value: string): value is PurchasePaymentStatus =>
+ (PURCHASE_PAYMENT_STATUSES as readonly string[]).includes(value);
+const isFulfillmentStatus = (value: string): value is FulfillmentStatus =>
+ (FULFILLMENT_STATUSES as readonly string[]).includes(value);
+
+export const listPurchasesAdminRoute = buildRoute({
+ pluginId: CONFIG_PLUGIN.pluginId,
+ adminStaffPermission: { module: "payments", permission: "can_view" },
+ route: {
+ method: "get",
+ description:
+ "Every purchase, newest first, with payment and fulfillment state.",
+ path: "/purchases",
+ request: {
+ query: zodPaginationQuery.extend(zodPaymentsAdminFilters.shape).extend({
+ fulfillment: z.string().max(200).optional(),
+ order: z.enum(["asc", "desc"]).optional(),
+ orderBy: z.enum(["createdAt", "amount"]).optional(),
+ status: z.string().max(200).optional(),
+ }),
+ },
+ responses: {
+ 200: {
+ content: {
+ "application/json": {
+ schema: z.object({
+ edges: z.array(zodAdminPurchase),
+ pageInfo: zodPaginationPageInfo,
+ }),
+ },
+ },
+ description: "One page of purchases",
+ },
+ },
+ },
+ handler: async c => {
+ const query = c.req.valid("query");
+ const statuses = commaList(query.status, isPaymentStatus);
+ const fulfillment = commaList(query.fulfillment, isFulfillmentStatus);
+
+ const page = await withPagination({
+ c,
+ orderBy: {
+ column: core_payments_purchases[query.orderBy ?? "createdAt"],
+ order: query.order ?? "desc",
+ },
+ params: { query },
+ primaryCursor: core_payments_purchases.id,
+ query: async ({ cursorSelection, limit, offset, orderBy, where }) =>
+ await c
+ .get("db")
+ .select({
+ ...getColumns(core_payments_purchases),
+ ...cursorSelection,
+ userName: core_users.name,
+ })
+ .from(core_payments_purchases)
+ .leftJoin(
+ core_users,
+ eq(core_users.id, core_payments_purchases.userId),
+ )
+ .where(where)
+ .orderBy(orderBy)
+ .limit(limit)
+ .offset(offset),
+ table: core_payments_purchases,
+ where: and(
+ statuses.length
+ ? inArray(core_payments_purchases.paymentStatus, statuses)
+ : undefined,
+ fulfillment.length
+ ? inArray(core_payments_purchases.fulfillmentStatus, fulfillment)
+ : undefined,
+ commonPaymentFilters(query, core_payments_purchases),
+ ),
+ });
+
+ return c.json({
+ edges: page.edges.map(row => ({
+ ...serializePurchase(row),
+ id: row.id,
+ lastError: row.lastError,
+ publicId: row.publicId,
+ providerScope: row.providerScope,
+ user:
+ row.userId === null
+ ? null
+ : { id: row.userId, name: row.userName ?? "" },
+ })),
+ pageInfo: page.pageInfo,
+ });
+ },
+});
+
+export const showPurchaseAdminRoute = buildRoute({
+ pluginId: CONFIG_PLUGIN.pluginId,
+ adminStaffPermission: { module: "payments", permission: "can_view" },
+ route: {
+ method: "get",
+ description:
+ "One purchase with its checkout attempts, fulfillment work, refunds, disputes and - for a subscription - its billing history. Provider references only, never payloads.",
+ path: "/purchases/{id}",
+ request: { params: z.object({ id: z.uuid() }) },
+ responses: {
+ 200: {
+ content: {
+ "application/json": {
+ schema: z.object({
+ adjustments: z.array(
+ z.object({
+ amount: z.number(),
+ createdAt: z.date(),
+ currency: z.string(),
+ externalId: z.string(),
+ invoiceId: z.number().nullable(),
+ kind: z.enum(["refund", "dispute"]),
+ reason: z.string().nullable(),
+ status: z.string(),
+ }),
+ ),
+ checkouts: z.array(
+ z.object({
+ createdAt: z.date(),
+ expiresAt: z.date(),
+ externalId: z.string().nullable(),
+ lastError: z.string().nullable(),
+ status: z.string(),
+ }),
+ ),
+ externalCustomerId: z.string().nullable(),
+ externalPaymentId: z.string().nullable(),
+ fulfillments: z.array(
+ z.object({
+ attempts: z.number(),
+ completedAt: z.date().nullable(),
+ effect: z.string(),
+ id: z.number(),
+ lastError: z.string().nullable(),
+ retryable: z.boolean(),
+ status: z.string(),
+ updatedAt: z.date(),
+ }),
+ ),
+ invoices: z.array(
+ z.object({
+ amountDue: z.number(),
+ amountPaid: z.number(),
+ billingReason: z.string().nullable(),
+ createdAt: z.date(),
+ currency: z.string(),
+ disputeStatus: z.string().nullable(),
+ externalId: z.string(),
+ id: z.number(),
+ paidAt: z.date().nullable(),
+ periodEnd: z.date().nullable(),
+ periodStart: z.date().nullable(),
+ refundedAmount: z.number(),
+ status: z.string(),
+ }),
+ ),
+ offerRegistered: z.boolean(),
+ purchase: zodAdminPurchase,
+ subscription: zodSubscription
+ .extend({
+ externalId: z.string(),
+ lastError: z.string().nullable(),
+ providerStatus: z.string(),
+ syncedAt: z.date(),
+ })
+ .nullable(),
+ }),
+ },
+ },
+ description: "Purchase detail",
+ },
+ 404: { description: "No such purchase" },
+ },
+ },
+ handler: async c => {
+ const db = c.get("db");
+ const [row] = await db
+ .select({
+ ...getColumns(core_payments_purchases),
+ userName: core_users.name,
+ })
+ .from(core_payments_purchases)
+ .leftJoin(core_users, eq(core_users.id, core_payments_purchases.userId))
+ .where(eq(core_payments_purchases.publicId, c.req.valid("param").id));
+
+ if (!row) throw new HTTPException(404, { message: "Purchase not found." });
+
+ const [checkouts, fulfillments, subscription] = await Promise.all([
+ db
+ .select()
+ .from(core_payments_checkouts)
+ .where(eq(core_payments_checkouts.purchaseId, row.id))
+ .orderBy(desc(core_payments_checkouts.id)),
+ db
+ .select()
+ .from(core_payments_fulfillments)
+ .where(eq(core_payments_fulfillments.purchaseId, row.id))
+ .orderBy(desc(core_payments_fulfillments.id)),
+ db
+ .select()
+ .from(core_payments_subscriptions)
+ .where(eq(core_payments_subscriptions.purchaseId, row.id))
+ .then(rows => rows.at(0)),
+ ]);
+
+ const invoices = subscription
+ ? await db
+ .select()
+ .from(core_payments_invoices)
+ .where(eq(core_payments_invoices.subscriptionId, subscription.id))
+ .orderBy(desc(core_payments_invoices.createdAt))
+ : [];
+
+ const invoiceIds = invoices.map(invoice => invoice.id);
+ const adjustments = await db
+ .select()
+ .from(core_payments_adjustments)
+ .where(
+ invoiceIds.length
+ ? inArray(core_payments_adjustments.invoiceId, invoiceIds)
+ : eq(core_payments_adjustments.purchaseId, row.id),
+ )
+ .orderBy(desc(core_payments_adjustments.createdAt));
+
+ return c.json({
+ adjustments: adjustments.map(adjustment => ({
+ amount: adjustment.amount,
+ createdAt: adjustment.createdAt,
+ currency: adjustment.currency,
+ externalId: adjustment.externalId,
+ invoiceId: adjustment.invoiceId,
+ kind: adjustment.kind,
+ reason: adjustment.reason,
+ status: adjustment.status,
+ })),
+ checkouts: checkouts.map(checkout => ({
+ createdAt: checkout.createdAt,
+ expiresAt: checkout.expiresAt,
+ externalId: checkout.externalId,
+ lastError: checkout.lastError,
+ status: checkout.status,
+ })),
+ externalCustomerId: row.externalCustomerId,
+ externalPaymentId: row.externalPaymentId,
+ fulfillments: fulfillments.map(fulfillment => ({
+ attempts: fulfillment.attempts,
+ completedAt: fulfillment.completedAt,
+ effect: fulfillment.effect,
+ id: fulfillment.id,
+ lastError: fulfillment.lastError,
+ retryable: fulfillment.completedGeneration < fulfillment.generation,
+ status: fulfillment.status,
+ updatedAt: fulfillment.updatedAt,
+ })),
+ invoices: invoices.map(invoice => ({
+ amountDue: invoice.amountDue,
+ amountPaid: invoice.amountPaid,
+ billingReason: invoice.billingReason,
+ createdAt: invoice.createdAt,
+ currency: invoice.currency,
+ disputeStatus: invoice.disputeStatus,
+ externalId: invoice.externalId,
+ id: invoice.id,
+ paidAt: invoice.paidAt,
+ periodEnd: invoice.periodEnd,
+ periodStart: invoice.periodStart,
+ refundedAmount: invoice.refundedAmount,
+ status: invoice.status,
+ })),
+ offerRegistered: c
+ .get("core")
+ .payments.offers.has(`${row.pluginId}:${row.offerId}`),
+ purchase: {
+ ...serializePurchase(row),
+ id: row.id,
+ lastError: row.lastError,
+ publicId: row.publicId,
+ providerScope: row.providerScope,
+ user:
+ row.userId === null
+ ? null
+ : { id: row.userId, name: row.userName ?? "" },
+ },
+ subscription: subscription
+ ? {
+ ...serializeSubscription(subscription),
+ externalId: subscription.externalId,
+ lastError: subscription.lastError,
+ providerStatus: subscription.providerStatus,
+ syncedAt: subscription.syncedAt,
+ }
+ : null,
+ });
+ },
+});
diff --git a/packages/vitnode/src/api/modules/admin/payments/routes/retry.route.ts b/packages/vitnode/src/api/modules/admin/payments/routes/retry.route.ts
new file mode 100644
index 0000000000..6b2c624676
--- /dev/null
+++ b/packages/vitnode/src/api/modules/admin/payments/routes/retry.route.ts
@@ -0,0 +1,68 @@
+import { HTTPException } from "hono/http-exception";
+import { z } from "zod";
+
+import { buildRoute } from "@/api/lib/route";
+import { retryFulfillment } from "@/api/models/payments/fulfillment";
+import { retryWebhookEvent } from "@/api/models/payments/webhooks";
+import { CONFIG_PLUGIN } from "@/config";
+
+const RETRY_PERMISSION = {
+ module: "payments",
+ permission: "can_retry",
+} as const;
+
+const retryResponses = {
+ 200: {
+ content: {
+ "application/json": { schema: z.object({ queued: z.literal(true) }) },
+ },
+ description: "Queued again",
+ },
+ 409: {
+ description: "Nothing to retry - it already completed or is not failed",
+ },
+} as const;
+
+export const retryFulfillmentAdminRoute = buildRoute({
+ pluginId: CONFIG_PLUGIN.pluginId,
+ adminStaffPermission: RETRY_PERMISSION,
+ route: {
+ method: "post",
+ description:
+ "Queue a fulfillment again through the normal queue. Idempotent: a fulfillment that already completed is never run again, so nothing can be granted twice.",
+ path: "/fulfillments/{id}/retry",
+ request: { params: z.object({ id: z.coerce.number().int().positive() }) },
+ responses: retryResponses,
+ },
+ handler: async c => {
+ if (!(await retryFulfillment(c, c.req.valid("param").id))) {
+ throw new HTTPException(409, {
+ message: "This fulfillment has nothing left to retry.",
+ });
+ }
+
+ return c.json({ queued: true as const });
+ },
+});
+
+export const retryWebhookEventAdminRoute = buildRoute({
+ pluginId: CONFIG_PLUGIN.pluginId,
+ adminStaffPermission: RETRY_PERMISSION,
+ route: {
+ method: "post",
+ description:
+ "Queue a failed webhook event for processing again. Processing re-reads the provider, so repeating it is safe.",
+ path: "/events/{id}/retry",
+ request: { params: z.object({ id: z.coerce.number().int().positive() }) },
+ responses: retryResponses,
+ },
+ handler: async c => {
+ if (!(await retryWebhookEvent(c, c.req.valid("param").id))) {
+ throw new HTTPException(409, {
+ message: "Only a failed event can be retried.",
+ });
+ }
+
+ return c.json({ queued: true as const });
+ },
+});
diff --git a/packages/vitnode/src/api/modules/admin/payments/routes/subscriptions.route.ts b/packages/vitnode/src/api/modules/admin/payments/routes/subscriptions.route.ts
new file mode 100644
index 0000000000..69c19408d1
--- /dev/null
+++ b/packages/vitnode/src/api/modules/admin/payments/routes/subscriptions.route.ts
@@ -0,0 +1,133 @@
+import { and, eq, getColumns, inArray } from "drizzle-orm";
+import { z } from "zod";
+
+import { buildRoute } from "@/api/lib/route";
+import {
+ withPagination,
+ zodPaginationPageInfo,
+ zodPaginationQuery,
+} from "@/api/lib/with-pagination";
+import {
+ serializeSubscription,
+ zodSubscription,
+} from "@/api/modules/payments/schema";
+import { CONFIG_PLUGIN } from "@/config";
+import {
+ core_payments_purchases,
+ core_payments_subscriptions,
+} from "@/database/payments";
+import { core_users } from "@/database/users";
+import {
+ SUBSCRIPTION_STATUSES,
+ type SubscriptionStatus,
+} from "@/payments/status";
+
+import {
+ commaList,
+ commonPaymentFilters,
+ zodPaymentsAdminFilters,
+} from "../filters";
+
+const isStatus = (value: string): value is SubscriptionStatus =>
+ (SUBSCRIPTION_STATUSES as readonly string[]).includes(value);
+
+export const listSubscriptionsAdminRoute = buildRoute({
+ pluginId: CONFIG_PLUGIN.pluginId,
+ adminStaffPermission: { module: "payments", permission: "can_view" },
+ route: {
+ method: "get",
+ description: "Every subscription, newest first.",
+ path: "/subscriptions",
+ request: {
+ query: zodPaginationQuery.extend(zodPaymentsAdminFilters.shape).extend({
+ order: z.enum(["asc", "desc"]).optional(),
+ orderBy: z.enum(["createdAt", "paidThrough"]).optional(),
+ status: z.string().max(200).optional(),
+ }),
+ },
+ responses: {
+ 200: {
+ content: {
+ "application/json": {
+ schema: z.object({
+ edges: z.array(
+ zodSubscription.extend({
+ id: z.number(),
+ lastError: z.string().nullable(),
+ publicId: z.string(),
+ purchaseId: z.string(),
+ user: z
+ .object({ id: z.number(), name: z.string() })
+ .nullable(),
+ }),
+ ),
+ pageInfo: zodPaginationPageInfo,
+ }),
+ },
+ },
+ description: "One page of subscriptions",
+ },
+ },
+ },
+ handler: async c => {
+ const query = c.req.valid("query");
+ const statuses = commaList(query.status, isStatus);
+
+ const page = await withPagination({
+ c,
+ orderBy: {
+ column: core_payments_subscriptions[query.orderBy ?? "createdAt"],
+ order: query.order ?? "desc",
+ },
+ params: { query },
+ primaryCursor: core_payments_subscriptions.id,
+ query: async ({ cursorSelection, limit, offset, orderBy, where }) =>
+ await c
+ .get("db")
+ .select({
+ ...getColumns(core_payments_subscriptions),
+ ...cursorSelection,
+ purchasePublicId: core_payments_purchases.publicId,
+ userName: core_users.name,
+ })
+ .from(core_payments_subscriptions)
+ .innerJoin(
+ core_payments_purchases,
+ eq(
+ core_payments_purchases.id,
+ core_payments_subscriptions.purchaseId,
+ ),
+ )
+ .leftJoin(
+ core_users,
+ eq(core_users.id, core_payments_subscriptions.userId),
+ )
+ .where(where)
+ .orderBy(orderBy)
+ .limit(limit)
+ .offset(offset),
+ table: core_payments_subscriptions,
+ where: and(
+ statuses.length
+ ? inArray(core_payments_subscriptions.status, statuses)
+ : undefined,
+ commonPaymentFilters(query, core_payments_subscriptions),
+ ),
+ });
+
+ return c.json({
+ edges: page.edges.map(row => ({
+ ...serializeSubscription(row),
+ id: row.id,
+ lastError: row.lastError,
+ publicId: row.publicId,
+ purchaseId: row.purchasePublicId,
+ user:
+ row.userId === null
+ ? null
+ : { id: row.userId, name: row.userName ?? "" },
+ })),
+ pageInfo: page.pageInfo,
+ });
+ },
+});
diff --git a/packages/vitnode/src/api/modules/middleware/route.test.ts b/packages/vitnode/src/api/modules/middleware/route.test.ts
index 4098a0a866..781861b8a3 100644
--- a/packages/vitnode/src/api/modules/middleware/route.test.ts
+++ b/packages/vitnode/src/api/modules/middleware/route.test.ts
@@ -61,6 +61,7 @@ const core = {
ssoAdapters: [],
},
navigation: presets,
+ payments: { config: null, offers: new Map() },
} as unknown as Context["var"]["core"];
const ai = { models: () => [] } as unknown as Context["var"]["ai"];
diff --git a/packages/vitnode/src/api/modules/middleware/route.ts b/packages/vitnode/src/api/modules/middleware/route.ts
index 8ad2995847..8f82d0682b 100644
--- a/packages/vitnode/src/api/modules/middleware/route.ts
+++ b/packages/vitnode/src/api/modules/middleware/route.ts
@@ -32,6 +32,8 @@ export const routeMiddlewareSchema = z.object({
isEmail: z.boolean(),
passkeys: z.boolean(),
password: z.boolean(),
+ /** Whether Payments is configured - the details live at `/payments/settings`. */
+ payments: z.boolean(),
navigation: z.array(zodPublicNavigationNodeSchema),
bottomBar: z.array(zodPublicNavigationItemSchema),
captcha: z
@@ -69,6 +71,7 @@ export const routeMiddleware = buildRoute({
isEmail: !!c.get("core").email?.adapter,
passkeys: c.get("core").authorization.passkeys.enabled,
password: c.get("core").authorization.password.enabled,
+ payments: !!c.get("core").payments.config,
navigation,
bottomBar,
sso: sso.map(s => ({
diff --git a/packages/vitnode/src/api/modules/payments/payments.module.ts b/packages/vitnode/src/api/modules/payments/payments.module.ts
new file mode 100644
index 0000000000..edebf3089f
--- /dev/null
+++ b/packages/vitnode/src/api/modules/payments/payments.module.ts
@@ -0,0 +1,37 @@
+import { buildModule } from "@/api/lib/module";
+import { CONFIG_PLUGIN } from "@/config";
+
+import { checkoutRoute } from "./routes/checkout.route";
+import { paymentOffersRoute } from "./routes/offers.route";
+import { portalRoute } from "./routes/portal.route";
+import {
+ cancelPurchaseRoute,
+ listPurchasesRoute,
+ showPurchaseRoute,
+} from "./routes/purchases.route";
+import { paymentsSettingsRoute } from "./routes/settings.route";
+import { listSubscriptionsRoute } from "./routes/subscriptions.route";
+import { webhookRoute } from "./routes/webhook.route";
+import {
+ fulfillPaymentTask,
+ processPaymentEventTask,
+ reconcilePaymentsCron,
+} from "./tasks/payments.tasks";
+
+export const paymentsModule = buildModule({
+ pluginId: CONFIG_PLUGIN.pluginId,
+ name: "payments",
+ routes: [
+ paymentsSettingsRoute,
+ paymentOffersRoute,
+ checkoutRoute,
+ listPurchasesRoute,
+ showPurchaseRoute,
+ cancelPurchaseRoute,
+ listSubscriptionsRoute,
+ portalRoute,
+ webhookRoute,
+ ],
+ cronJobs: [reconcilePaymentsCron],
+ queueTasks: [processPaymentEventTask, fulfillPaymentTask],
+});
diff --git a/packages/vitnode/src/api/modules/payments/payments.routes.integration.test.ts b/packages/vitnode/src/api/modules/payments/payments.routes.integration.test.ts
new file mode 100644
index 0000000000..6820694457
--- /dev/null
+++ b/packages/vitnode/src/api/modules/payments/payments.routes.integration.test.ts
@@ -0,0 +1,335 @@
+import { OpenAPIHono } from "@hono/zod-openapi";
+import { eq } from "drizzle-orm";
+import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
+
+// @vitest-environment node
+import type { EnvVitNode } from "@/api/middlewares/global.middleware";
+
+import { paymentsAdminModule } from "@/api/modules/admin/payments/payments.admin.module";
+import { core_payments_purchases } from "@/database/payments";
+import {
+ createPaymentsHarness,
+ must,
+ type PaymentsHarness,
+ TEST_PLUGIN_ID,
+} from "@/tests/payments";
+import { describePostgres } from "@/tests/postgres";
+import { grantStaffPermissions } from "@/tests/staff-permissions";
+
+import { paymentsModule } from "./payments.module";
+
+interface Buyer {
+ email: string;
+ id: number;
+ language: string;
+ name: string;
+}
+
+describePostgres("payments routes", () => {
+ let h: PaymentsHarness;
+ let app: OpenAPIHono;
+ let signedIn: Buyer | null = null;
+ let admin: null | { id: number } = null;
+
+ beforeAll(async () => {
+ h = await createPaymentsHarness();
+ app = new OpenAPIHono();
+ app.use("*", h.middleware({ admin: () => admin, user: () => signedIn }));
+ app.route("/payments", paymentsModule.hono);
+ app.route("/admin/payments", paymentsAdminModule.hono);
+ });
+
+ afterAll(async () => {
+ await h?.database.drop();
+ });
+
+ const as = (user: Buyer | null) => {
+ signedIn = user;
+ };
+
+ const post = async (path: string, body: unknown) =>
+ await app.request(path, {
+ body: JSON.stringify(body),
+ headers: { "content-type": "application/json" },
+ method: "POST",
+ });
+
+ const buy = async (user: Buyer, currency = "PLN") => {
+ as(user);
+ const response = await post("/payments/checkout", {
+ currency,
+ offerId: "lifetime",
+ pluginId: TEST_PLUGIN_ID,
+ });
+
+ return (await response.json()) as {
+ checkoutUrl: null | string;
+ purchase: { amount: number; id: string };
+ };
+ };
+
+ describe("settings", () => {
+ it("publishes what the UI needs and no credential", async () => {
+ const response = await app.request("/payments/settings");
+ const text = await response.text();
+
+ expect(response.status).toBe(200);
+ expect(JSON.parse(text)).toMatchObject({
+ defaultCurrency: "PLN",
+ enabled: true,
+ });
+ // Nothing an adapter keeps internally is part of the projection.
+ expect(text).not.toMatch(/sk_|whsec_|secret/i);
+ });
+
+ it("lists only purchasable prices for a plugin's offers", async () => {
+ const response = await app.request(
+ `/payments/offers?pluginId=${encodeURIComponent(TEST_PLUGIN_ID)}`,
+ );
+ const body = (await response.json()) as {
+ offers: { id: string; prices: { currency: string }[] }[];
+ };
+ const lifetime = must(body.offers.find(offer => offer.id === "lifetime"));
+
+ expect(lifetime.prices.map(price => price.currency).sort()).toEqual([
+ "EUR",
+ "PLN",
+ "USD",
+ ]);
+ });
+ });
+
+ describe("checkout", () => {
+ it("requires a signed-in buyer", async () => {
+ as(null);
+ const response = await post("/payments/checkout", {
+ currency: "PLN",
+ offerId: "lifetime",
+ pluginId: TEST_PLUGIN_ID,
+ });
+
+ expect(response.status).toBe(401);
+ });
+
+ it("ignores a price sent by the browser", async () => {
+ const user = await h.createUser();
+ as(user);
+ const response = await post("/payments/checkout", {
+ amount: 1,
+ currency: "PLN",
+ offerId: "lifetime",
+ pluginId: TEST_PLUGIN_ID,
+ userId: 999,
+ });
+ const body = (await response.json()) as { purchase: { amount: number } };
+
+ expect(response.status).toBe(200);
+ expect(body.purchase.amount).toBe(1900);
+ expect(response.headers.get("cache-control")).toBe("private, no-store");
+ });
+ });
+
+ describe("ownership", () => {
+ it("shows a purchase only to its buyer", async () => {
+ const alice = await h.createUser();
+ const bob = await h.createUser();
+ const { purchase } = await buy(alice);
+
+ as(bob);
+ const asBob = await app.request(`/payments/purchases/${purchase.id}`);
+ expect(asBob.status).toBe(404);
+
+ as(alice);
+ const asAlice = await app.request(`/payments/purchases/${purchase.id}`);
+ expect(asAlice.status).toBe(200);
+ expect(asAlice.headers.get("cache-control")).toBe("private, no-store");
+ });
+
+ it("lists only the signed-in user's purchases", async () => {
+ const alice = await h.createUser();
+ const bob = await h.createUser();
+ await buy(alice);
+
+ as(bob);
+ const response = await app.request("/payments/purchases");
+ const body = (await response.json()) as { edges: unknown[] };
+
+ expect(body.edges).toEqual([]);
+ });
+
+ it("refuses to cancel someone else's checkout", async () => {
+ const alice = await h.createUser();
+ const bob = await h.createUser();
+ const { purchase } = await buy(alice, "USD");
+
+ as(bob);
+ const response = await post(
+ `/payments/purchases/${purchase.id}/cancel`,
+ {},
+ );
+
+ expect(response.status).toBe(404);
+ });
+
+ it("never opens another user's billing portal", async () => {
+ const alice = await h.createUser();
+ const bob = await h.createUser();
+ await buy(alice, "EUR");
+
+ as(bob);
+ const response = await post("/payments/portal", {});
+
+ expect(response.status).toBe(404);
+ expect(((await response.json()) as { code: string }).code).toBe(
+ "no_billing_account",
+ );
+ });
+
+ it("refuses a portal return URL from the request", async () => {
+ const alice = await h.createUser();
+ await buy(alice, "EUR");
+ as(alice);
+
+ const response = await post("/payments/portal", {
+ returnTo: "https://evil.example/phish",
+ });
+
+ expect(response.status).toBe(400);
+ });
+ });
+
+ describe("returning from checkout", () => {
+ it("learns the outcome from the provider before any webhook", async () => {
+ const user = await h.createUser();
+ const { purchase } = await buy(user, "USD");
+ const checkout = must(
+ [...h.fake.checkouts.values()].find(
+ item => item.reference === purchase.id,
+ ),
+ );
+ h.fake.complete(checkout.id);
+
+ as(user);
+ const without = await app.request(`/payments/purchases/${purchase.id}`);
+ const withRefresh = await app.request(
+ `/payments/purchases/${purchase.id}?refresh=true`,
+ );
+
+ expect(
+ ((await without.json()) as { purchase: { paymentStatus: string } })
+ .purchase.paymentStatus,
+ ).toBe("awaiting_payment");
+ expect(
+ ((await withRefresh.json()) as { purchase: { paymentStatus: string } })
+ .purchase.paymentStatus,
+ ).toBe("paid");
+ await h.drainQueue();
+ });
+
+ it("asks the provider at most once every few seconds, without Redis", async () => {
+ const user = await h.createUser();
+ const { purchase } = await buy(user);
+ const retrieve = vi.spyOn(h.fake.provider.checkout, "retrieve");
+ const refresh = async () =>
+ await app.request(`/payments/purchases/${purchase.id}?refresh=true`);
+
+ as(user);
+ const polls = await Promise.all([refresh(), refresh(), refresh()]);
+ await refresh();
+
+ expect(polls.map(response => response.status)).toEqual([200, 200, 200]);
+ expect(retrieve).toHaveBeenCalledTimes(1);
+
+ await h.database.db
+ .update(core_payments_purchases)
+ .set({ refreshCheckedAt: new Date(Date.now() - 10_000) })
+ .where(eq(core_payments_purchases.publicId, purchase.id));
+ await refresh();
+
+ expect(retrieve).toHaveBeenCalledTimes(2);
+ retrieve.mockRestore();
+ });
+ });
+
+ describe("AdminCP", () => {
+ const grant = async (permissions: string[]) => {
+ const staff = await h.createUser();
+ await grantStaffPermissions(h.c.get("cache"), {
+ permissions: permissions.map(permission => ({
+ module: "payments",
+ permission,
+ plugin: "@vitnode/core",
+ })),
+ userId: staff.id,
+ });
+ admin = { id: staff.id };
+ };
+
+ it("refuses an administrator without the payments permission", async () => {
+ await grant([]);
+
+ expect((await app.request("/admin/payments/overview")).status).toBe(403);
+ expect((await app.request("/admin/payments/purchases")).status).toBe(403);
+ });
+
+ it("keeps revenue separate per currency", async () => {
+ const user = await h.createUser();
+ const { purchase } = await buy(user, "EUR");
+ const checkout = must(
+ [...h.fake.checkouts.values()].find(
+ item => item.reference === purchase.id,
+ ),
+ );
+ h.fake.complete(checkout.id);
+ as(user);
+ await app.request(`/payments/purchases/${purchase.id}?refresh=true`);
+ await grant(["can_view"]);
+
+ const response = await app.request("/admin/payments/overview");
+ const body = (await response.json()) as {
+ totals: { currency: string; oneTime: number }[];
+ };
+
+ expect(response.status).toBe(200);
+ expect(body.totals.map(total => total.currency)).toEqual(
+ [...new Set(body.totals.map(total => total.currency))].sort(),
+ );
+ expect(
+ must(body.totals.find(total => total.currency === "EUR")).oneTime,
+ ).toBe(450);
+ await h.drainQueue();
+ });
+
+ it("filters purchases by currency and status", async () => {
+ await grant(["can_view"]);
+
+ const response = await app.request(
+ "/admin/payments/purchases?currency=EUR&status=paid",
+ );
+ const body = (await response.json()) as {
+ edges: { currency: string; paymentStatus: string }[];
+ };
+
+ expect(body.edges.length).toBeGreaterThan(0);
+ expect(
+ body.edges.every(
+ edge => edge.currency === "EUR" && edge.paymentStatus === "paid",
+ ),
+ ).toBe(true);
+ });
+
+ it("needs the retry permission to retry, and says when there is nothing to retry", async () => {
+ await grant(["can_view"]);
+ expect(
+ (await post("/admin/payments/fulfillments/1/retry", {})).status,
+ ).toBe(403);
+
+ await grant(["can_view", "can_retry"]);
+ const response = await post(
+ "/admin/payments/fulfillments/999999/retry",
+ {},
+ );
+ expect(response.status).toBe(409);
+ });
+ });
+});
diff --git a/packages/vitnode/src/api/modules/payments/private-response.ts b/packages/vitnode/src/api/modules/payments/private-response.ts
new file mode 100644
index 0000000000..c497836a9b
--- /dev/null
+++ b/packages/vitnode/src/api/modules/payments/private-response.ts
@@ -0,0 +1,13 @@
+import type { MiddlewareHandler } from "hono";
+
+/**
+ * Billing responses are one person's records. A shared cache keyed on the URL
+ * must never store one and hand it to the next visitor, and neither should the
+ * browser's back/forward cache keep a stale "awaiting payment".
+ */
+const PRIVATE_NO_STORE = "private, no-store";
+
+export const privateResponse: MiddlewareHandler = async (c, next) => {
+ await next();
+ c.res.headers.set("Cache-Control", PRIVATE_NO_STORE);
+};
diff --git a/packages/vitnode/src/api/modules/payments/routes/checkout.route.ts b/packages/vitnode/src/api/modules/payments/routes/checkout.route.ts
new file mode 100644
index 0000000000..3510e57787
--- /dev/null
+++ b/packages/vitnode/src/api/modules/payments/routes/checkout.route.ts
@@ -0,0 +1,71 @@
+import { z } from "zod";
+
+import { buildRoute } from "@/api/lib/route";
+import { startCheckout } from "@/api/models/payments/checkout";
+import { requirePaymentsUser } from "@/api/models/payments/shared";
+import { CONFIG_PLUGIN } from "@/config";
+import { BILLING_INTERVALS } from "@/payments/status";
+
+import { privateResponse } from "../private-response";
+import {
+ paymentsErrorResponses,
+ serializePurchase,
+ zodPurchase,
+} from "../schema";
+
+export const checkoutRoute = buildRoute({
+ pluginId: CONFIG_PLUGIN.pluginId,
+ route: {
+ method: "post",
+ description:
+ "Start or resume a hosted checkout for the signed-in user. The body names an offer, a currency and (for subscriptions) an interval; the price, buyer and return URLs are resolved on the server. Repeating a request with the same `idempotencyKey` returns the same purchase.",
+ path: "/checkout",
+ middleware: [privateResponse],
+ request: {
+ body: {
+ content: {
+ "application/json": {
+ schema: z.object({
+ currency: z.string().regex(/^[A-Z]{3}$/),
+ idempotencyKey: z
+ .string()
+ .regex(/^[A-Za-z0-9_-]{8,100}$/)
+ .optional(),
+ interval: z.enum(BILLING_INTERVALS).nullable().default(null),
+ offerId: z.string().min(1).max(64),
+ pluginId: z.string().min(1).max(100),
+ provider: z.string().max(32).optional(),
+ }),
+ },
+ },
+ required: true,
+ },
+ },
+ responses: {
+ 200: {
+ content: {
+ "application/json": {
+ schema: z.object({
+ checkoutUrl: z.string().nullable(),
+ purchase: zodPurchase,
+ }),
+ },
+ },
+ description:
+ "The purchase, and the URL to send the buyer to - `null` when it is already paid, processing or over",
+ },
+ ...paymentsErrorResponses,
+ 502: { description: "The provider refused to create the checkout" },
+ },
+ },
+ handler: async c => {
+ const user = requirePaymentsUser(c);
+ const body = c.req.valid("json");
+ const result = await startCheckout(c, user, body);
+
+ return c.json({
+ checkoutUrl: result.checkoutUrl,
+ purchase: serializePurchase(result.purchase),
+ });
+ },
+});
diff --git a/packages/vitnode/src/api/modules/payments/routes/offers.route.ts b/packages/vitnode/src/api/modules/payments/routes/offers.route.ts
new file mode 100644
index 0000000000..add2615039
--- /dev/null
+++ b/packages/vitnode/src/api/modules/payments/routes/offers.route.ts
@@ -0,0 +1,76 @@
+import { z } from "zod";
+
+import { buildRoute } from "@/api/lib/route";
+import { CONFIG_PLUGIN } from "@/config";
+import { purchasablePrices } from "@/payments/config";
+import { BILLING_INTERVALS, OFFER_MODES } from "@/payments/status";
+
+export const paymentOffersRoute = buildRoute({
+ pluginId: CONFIG_PLUGIN.pluginId,
+ route: {
+ method: "get",
+ description:
+ "The offers a plugin sells, with only the prices a buyer can choose right now: the offer's explicit prices intersected with the enabled currencies and the provider's capabilities.",
+ path: "/offers",
+ request: {
+ query: z.object({
+ pluginId: z.string().min(1).max(100),
+ provider: z.string().max(32).optional(),
+ }),
+ },
+ responses: {
+ 200: {
+ content: {
+ "application/json": {
+ schema: z.object({
+ enabled: z.boolean(),
+ offers: z.array(
+ z.object({
+ id: z.string(),
+ mode: z.enum(OFFER_MODES),
+ name: z.string(),
+ nameKey: z.string().nullable(),
+ pluginId: z.string(),
+ prices: z.array(
+ z.object({
+ amount: z.number().int(),
+ currency: z.string(),
+ interval: z.enum(BILLING_INTERVALS).nullable(),
+ }),
+ ),
+ }),
+ ),
+ }),
+ },
+ },
+ description: "Offers and their purchasable prices",
+ },
+ },
+ },
+ handler: c => {
+ const { pluginId, provider: providerId } = c.req.valid("query");
+ const { config, offers } = c.get("core").payments;
+ const provider = config
+ ? providerId
+ ? config.providers.find(item => item.id === providerId)
+ : config.defaultProvider
+ : undefined;
+
+ return c.json({
+ enabled: !!config,
+ offers: [...offers.values()]
+ .filter(entry => entry.pluginId === pluginId)
+ .map(({ offer }) => ({
+ id: offer.id,
+ mode: offer.mode,
+ name: offer.name,
+ nameKey: offer.nameKey ?? null,
+ pluginId,
+ prices:
+ config && provider
+ ? purchasablePrices(config, provider, offer)
+ : [],
+ })),
+ });
+ },
+});
diff --git a/packages/vitnode/src/api/modules/payments/routes/portal.route.ts b/packages/vitnode/src/api/modules/payments/routes/portal.route.ts
new file mode 100644
index 0000000000..23cf61f9ab
--- /dev/null
+++ b/packages/vitnode/src/api/modules/payments/routes/portal.route.ts
@@ -0,0 +1,67 @@
+import { z } from "zod";
+
+import { buildRoute } from "@/api/lib/route";
+import { createPortalSession } from "@/api/models/payments/checkout";
+import {
+ findRegisteredOffer,
+ requirePaymentsUser,
+} from "@/api/models/payments/shared";
+import { CONFIG_PLUGIN } from "@/config";
+
+import { privateResponse } from "../private-response";
+import { paymentsErrorResponses } from "../schema";
+
+/** Where billing settings live - the portal's default way back. */
+export const BILLING_SETTINGS_PATH = "/settings/billing";
+
+export const portalRoute = buildRoute({
+ pluginId: CONFIG_PLUGIN.pluginId,
+ route: {
+ method: "post",
+ description:
+ "Open the provider's billing portal for the signed-in user's own billing account. The return destination is either billing settings or an offer's declared page - never a URL from the request.",
+ path: "/portal",
+ middleware: [privateResponse],
+ request: {
+ body: {
+ content: {
+ "application/json": {
+ schema: z.object({
+ provider: z.string().max(32).optional(),
+ returnTo: z
+ .object({
+ offerId: z.string().min(1).max(64),
+ pluginId: z.string().min(1).max(100),
+ })
+ .optional(),
+ }),
+ },
+ },
+ required: true,
+ },
+ },
+ responses: {
+ 200: {
+ content: {
+ "application/json": { schema: z.object({ url: z.string() }) },
+ },
+ description: "The portal URL to send the user to",
+ },
+ ...paymentsErrorResponses,
+ },
+ },
+ handler: async c => {
+ const user = requirePaymentsUser(c);
+ const { provider, returnTo } = c.req.valid("json");
+ const offer = returnTo
+ ? findRegisteredOffer(c, returnTo.pluginId, returnTo.offerId)
+ : undefined;
+
+ const url = await createPortalSession(c, user.id, {
+ providerId: provider,
+ returnPath: offer?.offer.returnPath ?? BILLING_SETTINGS_PATH,
+ });
+
+ return c.json({ url });
+ },
+});
diff --git a/packages/vitnode/src/api/modules/payments/routes/purchases.route.ts b/packages/vitnode/src/api/modules/payments/routes/purchases.route.ts
new file mode 100644
index 0000000000..6d0850d2ad
--- /dev/null
+++ b/packages/vitnode/src/api/modules/payments/routes/purchases.route.ts
@@ -0,0 +1,261 @@
+import { and, eq, getColumns, isNull, lt, or } from "drizzle-orm";
+import { z } from "zod";
+
+import type {
+ PaymentsContext,
+ PurchaseRow,
+} from "@/api/models/payments/shared";
+
+import { buildRoute } from "@/api/lib/route";
+import {
+ withPagination,
+ zodPaginationPageInfo,
+ zodPaginationQuery,
+} from "@/api/lib/with-pagination";
+import { cancelPurchase } from "@/api/models/payments/checkout";
+import {
+ describePaymentsError,
+ paymentsHttpError,
+ providerForRow,
+ requirePaymentsUser,
+} from "@/api/models/payments/shared";
+import { refreshPurchase } from "@/api/models/payments/sync";
+import { CONFIG_PLUGIN } from "@/config";
+import {
+ core_payments_checkouts,
+ core_payments_purchases,
+} from "@/database/payments";
+
+import { privateResponse } from "../private-response";
+import {
+ paymentsErrorResponses,
+ serializePurchase,
+ zodPublicId,
+ zodPurchase,
+} from "../schema";
+
+/** How often one purchase may trigger a provider read from the browser. */
+const REFRESH_THROTTLE_SECONDS = 5;
+
+// An atomic row claim, so the throttle holds across instances without Redis.
+const claimRefresh = async (
+ c: PaymentsContext,
+ purchaseId: number,
+): Promise => {
+ const [claimed] = await c
+ .get("db")
+ .update(core_payments_purchases)
+ .set({ refreshCheckedAt: new Date() })
+ .where(
+ and(
+ eq(core_payments_purchases.id, purchaseId),
+ or(
+ isNull(core_payments_purchases.refreshCheckedAt),
+ lt(
+ core_payments_purchases.refreshCheckedAt,
+ new Date(Date.now() - REFRESH_THROTTLE_SECONDS * 1000),
+ ),
+ ),
+ ),
+ )
+ .returning({ id: core_payments_purchases.id });
+
+ return !!claimed;
+};
+
+/**
+ * The purchase only if the signed-in user owns it. Not owning it reads exactly
+ * like it not existing - an opaque id is never authorization by itself.
+ */
+export const findOwnPurchase = async (
+ c: PaymentsContext,
+ publicId: string,
+): Promise => {
+ const user = requirePaymentsUser(c);
+ const [purchase] = await c
+ .get("db")
+ .select()
+ .from(core_payments_purchases)
+ .where(
+ and(
+ eq(core_payments_purchases.publicId, publicId),
+ eq(core_payments_purchases.userId, user.id),
+ ),
+ );
+
+ if (!purchase) {
+ throw paymentsHttpError(404, "purchase_not_found", "Purchase not found.");
+ }
+
+ return purchase;
+};
+
+const openCheckoutUrl = async (
+ c: PaymentsContext,
+ purchase: PurchaseRow,
+): Promise => {
+ if (purchase.paymentStatus !== "awaiting_payment") return null;
+
+ const [checkout] = await c
+ .get("db")
+ .select({
+ expiresAt: core_payments_checkouts.expiresAt,
+ url: core_payments_checkouts.url,
+ })
+ .from(core_payments_checkouts)
+ .where(
+ and(
+ eq(core_payments_checkouts.purchaseId, purchase.id),
+ eq(core_payments_checkouts.status, "open"),
+ ),
+ );
+
+ return checkout && checkout.expiresAt > new Date() ? checkout.url : null;
+};
+
+export const listPurchasesRoute = buildRoute({
+ pluginId: CONFIG_PLUGIN.pluginId,
+ route: {
+ method: "get",
+ description: "The signed-in user's purchases, newest first.",
+ path: "/purchases",
+ middleware: [privateResponse],
+ request: { query: zodPaginationQuery },
+ responses: {
+ 200: {
+ content: {
+ "application/json": {
+ schema: z.object({
+ edges: z.array(zodPurchase),
+ pageInfo: zodPaginationPageInfo,
+ }),
+ },
+ },
+ description: "One page of purchases",
+ },
+ 401: paymentsErrorResponses[401],
+ },
+ },
+ handler: async c => {
+ const user = requirePaymentsUser(c);
+ const page = await withPagination({
+ c,
+ orderBy: { column: core_payments_purchases.createdAt, order: "desc" },
+ params: { query: c.req.valid("query") },
+ primaryCursor: core_payments_purchases.id,
+ query: async ({ cursorSelection, limit, offset, orderBy, where }) =>
+ await c
+ .get("db")
+ .select({
+ ...getColumns(core_payments_purchases),
+ ...cursorSelection,
+ })
+ .from(core_payments_purchases)
+ .where(where)
+ .orderBy(orderBy)
+ .limit(limit)
+ .offset(offset),
+ table: core_payments_purchases,
+ where: eq(core_payments_purchases.userId, user.id),
+ });
+
+ return c.json({
+ edges: page.edges.map(row => serializePurchase(row as PurchaseRow)),
+ pageInfo: page.pageInfo,
+ });
+ },
+});
+
+export const showPurchaseRoute = buildRoute({
+ pluginId: CONFIG_PLUGIN.pluginId,
+ route: {
+ method: "get",
+ description:
+ "One of the signed-in user's purchases. With `refresh=true`, an unfinished purchase is first checked with the provider - which is how the page the buyer returns to learns the outcome before the webhook is processed.",
+ path: "/purchases/{id}",
+ middleware: [privateResponse],
+ request: {
+ params: zodPublicId,
+ query: z.object({ refresh: z.enum(["true", "false"]).optional() }),
+ },
+ responses: {
+ 200: {
+ content: {
+ "application/json": {
+ schema: z.object({
+ checkoutUrl: z.string().nullable(),
+ purchase: zodPurchase,
+ }),
+ },
+ },
+ description: "The purchase as it stands",
+ },
+ 401: paymentsErrorResponses[401],
+ 404: paymentsErrorResponses[404],
+ },
+ },
+ handler: async c => {
+ const { id } = c.req.valid("param");
+ let purchase = await findOwnPurchase(c, id);
+
+ if (
+ c.req.valid("query").refresh === "true" &&
+ (purchase.paymentStatus === "awaiting_payment" ||
+ purchase.paymentStatus === "processing") &&
+ c.get("core").payments.config &&
+ (await claimRefresh(c, purchase.id))
+ ) {
+ try {
+ purchase = await refreshPurchase(
+ c,
+ providerForRow(c, purchase),
+ purchase,
+ );
+ } catch (error) {
+ // The webhook and reconciliation still cover it; the buyer just sees
+ // the last known state.
+ await c
+ .get("log")
+ .warn(
+ `[Payments] Refresh of ${purchase.publicId} failed: ${describePaymentsError(error)}`,
+ );
+ }
+ }
+
+ return c.json({
+ checkoutUrl: await openCheckoutUrl(c, purchase),
+ purchase: serializePurchase(purchase),
+ });
+ },
+});
+
+export const cancelPurchaseRoute = buildRoute({
+ pluginId: CONFIG_PLUGIN.pluginId,
+ route: {
+ method: "post",
+ description:
+ "Cancel the signed-in user's unfinished checkout, so a new one can be started.",
+ path: "/purchases/{id}/cancel",
+ middleware: [privateResponse],
+ request: { params: zodPublicId },
+ responses: {
+ 200: {
+ content: {
+ "application/json": { schema: z.object({ purchase: zodPurchase }) },
+ },
+ description:
+ "The purchase after cancelling - paid wins if it already was",
+ },
+ 401: paymentsErrorResponses[401],
+ 404: paymentsErrorResponses[404],
+ 409: paymentsErrorResponses[409],
+ },
+ },
+ handler: async c => {
+ const purchase = await findOwnPurchase(c, c.req.valid("param").id);
+
+ return c.json({
+ purchase: serializePurchase(await cancelPurchase(c, purchase)),
+ });
+ },
+});
diff --git a/packages/vitnode/src/api/modules/payments/routes/settings.route.ts b/packages/vitnode/src/api/modules/payments/routes/settings.route.ts
new file mode 100644
index 0000000000..a8fd2a48f5
--- /dev/null
+++ b/packages/vitnode/src/api/modules/payments/routes/settings.route.ts
@@ -0,0 +1,27 @@
+import { buildRoute } from "@/api/lib/route";
+import { CONFIG_PLUGIN } from "@/config";
+import { publicPaymentsSettings } from "@/payments/config";
+
+import { zodPaymentsSettings } from "../schema";
+
+export const paymentsSettingsRoute = buildRoute({
+ pluginId: CONFIG_PLUGIN.pluginId,
+ route: {
+ method: "get",
+ description:
+ "Whether Payments is enabled, and the currencies and providers the UI may offer. Never includes keys or secrets.",
+ path: "/settings",
+ responses: {
+ 200: {
+ content: { "application/json": { schema: zodPaymentsSettings } },
+ description: "Public payments settings",
+ },
+ },
+ },
+ handler: c =>
+ c.json(
+ zodPaymentsSettings.parse(
+ publicPaymentsSettings(c.get("core").payments.config),
+ ),
+ ),
+});
diff --git a/packages/vitnode/src/api/modules/payments/routes/subscriptions.route.ts b/packages/vitnode/src/api/modules/payments/routes/subscriptions.route.ts
new file mode 100644
index 0000000000..fc813b4398
--- /dev/null
+++ b/packages/vitnode/src/api/modules/payments/routes/subscriptions.route.ts
@@ -0,0 +1,76 @@
+import { eq, getColumns } from "drizzle-orm";
+import { z } from "zod";
+
+import type { SubscriptionRow } from "@/api/models/payments/shared";
+
+import { buildRoute } from "@/api/lib/route";
+import {
+ withPagination,
+ zodPaginationPageInfo,
+ zodPaginationQuery,
+} from "@/api/lib/with-pagination";
+import { requirePaymentsUser } from "@/api/models/payments/shared";
+import { CONFIG_PLUGIN } from "@/config";
+import { core_payments_subscriptions } from "@/database/payments";
+
+import { privateResponse } from "../private-response";
+import {
+ paymentsErrorResponses,
+ serializeSubscription,
+ zodSubscription,
+} from "../schema";
+
+export const listSubscriptionsRoute = buildRoute({
+ pluginId: CONFIG_PLUGIN.pluginId,
+ route: {
+ method: "get",
+ description: "The signed-in user's subscriptions, newest first.",
+ path: "/subscriptions",
+ middleware: [privateResponse],
+ request: { query: zodPaginationQuery },
+ responses: {
+ 200: {
+ content: {
+ "application/json": {
+ schema: z.object({
+ edges: z.array(zodSubscription),
+ pageInfo: zodPaginationPageInfo,
+ }),
+ },
+ },
+ description: "One page of subscriptions",
+ },
+ 401: paymentsErrorResponses[401],
+ },
+ },
+ handler: async c => {
+ const user = requirePaymentsUser(c);
+ const page = await withPagination({
+ c,
+ orderBy: { column: core_payments_subscriptions.createdAt, order: "desc" },
+ params: { query: c.req.valid("query") },
+ primaryCursor: core_payments_subscriptions.id,
+ query: async ({ cursorSelection, limit, offset, orderBy, where }) =>
+ await c
+ .get("db")
+ .select({
+ ...getColumns(core_payments_subscriptions),
+ ...cursorSelection,
+ })
+ .from(core_payments_subscriptions)
+ .where(where)
+ .orderBy(orderBy)
+ .limit(limit)
+ .offset(offset),
+ table: core_payments_subscriptions,
+ where: eq(core_payments_subscriptions.userId, user.id),
+ });
+
+ return c.json({
+ edges: page.edges.map(row =>
+ serializeSubscription(row as SubscriptionRow),
+ ),
+ pageInfo: page.pageInfo,
+ });
+ },
+});
diff --git a/packages/vitnode/src/api/modules/payments/routes/webhook.route.ts b/packages/vitnode/src/api/modules/payments/routes/webhook.route.ts
new file mode 100644
index 0000000000..aaf57658d4
--- /dev/null
+++ b/packages/vitnode/src/api/modules/payments/routes/webhook.route.ts
@@ -0,0 +1,129 @@
+import { bodyLimit } from "hono/body-limit";
+import { z } from "zod";
+
+import { buildRoute } from "@/api/lib/route";
+import { describePaymentsError } from "@/api/models/payments/shared";
+import { acceptWebhookEvent } from "@/api/models/payments/webhooks";
+import { processQueueTasksByIds } from "@/api/modules/queue/helpers/process-queue-tasks";
+import { CONFIG_PLUGIN } from "@/config";
+import {
+ isPaymentWebhookSignatureError,
+ type ProviderWebhookEvent,
+} from "@/payments/provider";
+
+/**
+ * Provider events are small. The cap is far below the API's general body limit,
+ * so an unauthenticated caller cannot make this route buffer much before the
+ * signature has been checked.
+ */
+export const WEBHOOK_MAX_BODY_BYTES = 512 * 1024;
+
+export const webhookRoute = buildRoute({
+ pluginId: CONFIG_PLUGIN.pluginId,
+ route: {
+ method: "post",
+ description:
+ "Receives payment provider webhooks. No session: every request is authenticated by the provider's signature over the exact bytes received. Answers 2xx only after the event is stored and queued.",
+ path: "/webhooks/{providerId}",
+ middleware: [
+ bodyLimit({
+ maxSize: WEBHOOK_MAX_BODY_BYTES,
+ onError: c => c.json({ error: "Payload Too Large" }, 413),
+ }),
+ ],
+ request: {
+ params: z.object({ providerId: z.string().regex(/^[a-z0-9-]{1,32}$/) }),
+ },
+ responses: {
+ 200: {
+ content: {
+ "application/json": {
+ schema: z.object({
+ duplicate: z.boolean().optional(),
+ ignored: z.boolean().optional(),
+ received: z.boolean(),
+ }),
+ },
+ },
+ description:
+ "Stored and queued, a duplicate, or an event type VitNode does not use",
+ },
+ 400: { description: "The signature did not verify" },
+ 404: { description: "Payments is off or the provider is not configured" },
+ 413: { description: "Larger than any provider event" },
+ },
+ },
+ handler: async c => {
+ const provider = c
+ .get("core")
+ .payments.config?.providers.find(
+ item => item.id === c.req.valid("param").providerId,
+ );
+
+ if (!provider) return c.json({ received: false }, 404);
+
+ // The bytes exactly as they arrived. Parsing and re-serialising would
+ // change them, and the signature is over these bytes, not the JSON.
+ const body = new Uint8Array(await c.req.arrayBuffer());
+
+ let event: ProviderWebhookEvent;
+ try {
+ event = await provider.webhooks.verify({
+ body,
+ headers: c.req.raw.headers,
+ });
+ } catch (error) {
+ if (isPaymentWebhookSignatureError(error)) {
+ await c
+ .get("log")
+ .warn(
+ `[Payments] Rejected a ${provider.id} webhook: ${error.message}`,
+ );
+
+ return c.json({ received: false }, 400);
+ }
+
+ throw error;
+ }
+
+ const { target } = event;
+
+ if (!target) return c.json({ ignored: true, received: true });
+
+ if (event.scope !== provider.scope) {
+ await c
+ .get("log")
+ .warn(
+ `[Payments] Ignored ${provider.id} event ${event.id}: it is from "${event.scope}" but this install uses "${provider.scope}" keys.`,
+ );
+
+ return c.json({ ignored: true, received: true });
+ }
+
+ // Throws if the event cannot be stored and queued - the provider then sees
+ // a 5xx and redelivers, which is exactly what should happen.
+ const accepted = await acceptWebhookEvent(c, provider, {
+ ...event,
+ target,
+ });
+
+ if (accepted.status === "duplicate") {
+ return c.json({ duplicate: true, received: true });
+ }
+
+ // Best effort, after acceptance: the queue worker is what guarantees it.
+ if (accepted.queueId === null) return c.json({ received: true });
+
+ void processQueueTasksByIds(c, [accepted.queueId]).catch(
+ async (error: unknown) => {
+ await c
+ .get("log")
+ .warn(
+ `[Payments] Immediate processing deferred to the queue: ${describePaymentsError(error)}`,
+ );
+ },
+ );
+
+ return c.json({ received: true });
+ },
+});
diff --git a/packages/vitnode/src/api/modules/payments/schema.ts b/packages/vitnode/src/api/modules/payments/schema.ts
new file mode 100644
index 0000000000..5b97c6e848
--- /dev/null
+++ b/packages/vitnode/src/api/modules/payments/schema.ts
@@ -0,0 +1,150 @@
+import { z } from "zod";
+
+import type {
+ PurchaseRow,
+ SubscriptionRow,
+} from "@/api/models/payments/shared";
+
+import { CURRENCY_DISPLAYS } from "@/payments/money";
+import {
+ BILLING_INTERVALS,
+ DISPUTE_STATUSES,
+ FULFILLMENT_STATUSES,
+ OFFER_MODES,
+ PURCHASE_PAYMENT_STATUSES,
+ purchaseDisplayState,
+ REFUND_STATUSES,
+ SUBSCRIPTION_STATUSES,
+ subscriptionDisplayState,
+} from "@/payments/status";
+
+export const zodPaymentsSettings = z.object({
+ currencies: z.array(
+ z.object({ code: z.string(), currencyDisplay: z.enum(CURRENCY_DISPLAYS) }),
+ ),
+ defaultCurrency: z.string().nullable(),
+ defaultProvider: z.string().nullable(),
+ enabled: z.boolean(),
+ providers: z.array(
+ z.object({
+ capabilities: z.object({
+ currencies: z.array(z.string()),
+ customerPortal: z.boolean(),
+ intervals: z.array(z.enum(BILLING_INTERVALS)),
+ oneTimePayments: z.boolean(),
+ subscriptions: z.boolean(),
+ }),
+ id: z.string(),
+ name: z.string(),
+ }),
+ ),
+});
+
+export const zodPurchase = z.object({
+ amount: z.number().int(),
+ createdAt: z.date(),
+ currency: z.string(),
+ displayState: z.enum([
+ "awaiting_payment",
+ "failed",
+ "fulfilled",
+ "fulfillment_failed",
+ "fulfillment_pending",
+ "paid",
+ "partially_refunded",
+ "processing",
+ "refunded",
+ ]),
+ disputeStatus: z.enum(DISPUTE_STATUSES).nullable(),
+ fulfillmentStatus: z.enum(FULFILLMENT_STATUSES),
+ id: z.string(),
+ interval: z.enum(BILLING_INTERVALS).nullable(),
+ mode: z.enum(OFFER_MODES),
+ offerId: z.string(),
+ offerName: z.string(),
+ paidAt: z.date().nullable(),
+ paymentStatus: z.enum(PURCHASE_PAYMENT_STATUSES),
+ pluginId: z.string(),
+ provider: z.string(),
+ refundedAmount: z.number().int(),
+ refundStatus: z.enum(REFUND_STATUSES),
+});
+
+export const zodSubscription = z.object({
+ amount: z.number().int(),
+ cancelAt: z.date().nullable(),
+ cancelAtPeriodEnd: z.boolean(),
+ createdAt: z.date(),
+ currency: z.string(),
+ currentPeriodEnd: z.date().nullable(),
+ displayState: z.enum([
+ "active",
+ "cancellation_scheduled",
+ "ended",
+ "incomplete",
+ "needs_attention",
+ ]),
+ endedAt: z.date().nullable(),
+ id: z.string(),
+ interval: z.enum(BILLING_INTERVALS),
+ offerId: z.string(),
+ offerName: z.string(),
+ paidThrough: z.date().nullable(),
+ pluginId: z.string(),
+ provider: z.string(),
+ status: z.enum(SUBSCRIPTION_STATUSES),
+});
+
+/** A purchase as its owner may see it - no internal ids, no provider refs. */
+export const serializePurchase = (
+ row: PurchaseRow,
+): z.infer => ({
+ amount: row.amount,
+ createdAt: row.createdAt,
+ currency: row.currency,
+ displayState: purchaseDisplayState(row),
+ disputeStatus: row.disputeStatus,
+ fulfillmentStatus: row.fulfillmentStatus,
+ id: row.publicId,
+ interval: row.interval,
+ mode: row.mode,
+ offerId: row.offerId,
+ offerName: row.offerName,
+ paidAt: row.paidAt,
+ paymentStatus: row.paymentStatus,
+ pluginId: row.pluginId,
+ provider: row.provider,
+ refundedAmount: row.refundedAmount,
+ refundStatus: row.refundStatus,
+});
+
+export const serializeSubscription = (
+ row: SubscriptionRow,
+): z.infer => ({
+ amount: row.amount,
+ cancelAt: row.cancelAt,
+ cancelAtPeriodEnd: row.cancelAtPeriodEnd,
+ createdAt: row.createdAt,
+ currency: row.currency,
+ currentPeriodEnd: row.currentPeriodEnd,
+ displayState: subscriptionDisplayState(row),
+ endedAt: row.endedAt,
+ id: row.publicId,
+ interval: row.interval,
+ offerId: row.offerId,
+ offerName: row.offerName,
+ paidThrough: row.paidThrough,
+ pluginId: row.pluginId,
+ provider: row.provider,
+ status: row.status,
+});
+
+export const zodPublicId = z.object({ id: z.uuid() });
+
+export const paymentsErrorResponses = {
+ 400: { description: "The request cannot be fulfilled as asked" },
+ 401: { description: "Not signed in" },
+ 404: { description: "Not found, or not yours" },
+ 409: { description: "Conflicts with the current state" },
+ 503: { description: "Payments are disabled, or the provider did not answer" },
+} as const;
diff --git a/packages/vitnode/src/api/modules/payments/tasks/payments.tasks.ts b/packages/vitnode/src/api/modules/payments/tasks/payments.tasks.ts
new file mode 100644
index 0000000000..d290211288
--- /dev/null
+++ b/packages/vitnode/src/api/modules/payments/tasks/payments.tasks.ts
@@ -0,0 +1,47 @@
+import { z } from "zod";
+
+import { buildCron } from "@/api/lib/cron";
+import { buildQueueTask } from "@/api/lib/queue";
+import {
+ FULFILL_TASK,
+ runFulfillment,
+} from "@/api/models/payments/fulfillment";
+import { reconcilePayments } from "@/api/models/payments/reconcile";
+import {
+ PROCESS_EVENT_TASK,
+ processWebhookEvent,
+} from "@/api/models/payments/webhooks";
+
+export const processPaymentEventTask = buildQueueTask({
+ name: PROCESS_EVENT_TASK,
+ description:
+ "Re-read a payment provider object named by a verified webhook and apply it.",
+ maxAttempts: 8,
+ handler: async (c, payload) => {
+ const { eventId } = z.object({ eventId: z.number().int() }).parse(payload);
+ await processWebhookEvent(c, eventId);
+ },
+});
+
+export const fulfillPaymentTask = buildQueueTask({
+ name: FULFILL_TASK,
+ description:
+ "Run a plugin's handler for a paid purchase, a refund or a subscription change.",
+ maxAttempts: 5,
+ handler: async (c, payload) => {
+ const { fulfillmentId } = z
+ .object({ fulfillmentId: z.number().int() })
+ .parse(payload);
+ await runFulfillment(c, fulfillmentId);
+ },
+});
+
+export const reconcilePaymentsCron = buildCron({
+ name: "payments-reconcile",
+ description:
+ "Check unfinished checkouts, processing payments and subscriptions due a renewal with the provider, and re-queue stuck payment work.",
+ schedule: "*/5 * * * *",
+ handler: async c => {
+ await reconcilePayments(c);
+ },
+});
diff --git a/packages/vitnode/src/api/plugin.ts b/packages/vitnode/src/api/plugin.ts
index 8a68cd9cc2..db5d2d18e3 100644
--- a/packages/vitnode/src/api/plugin.ts
+++ b/packages/vitnode/src/api/plugin.ts
@@ -20,6 +20,7 @@ import { cronModule } from "./modules/cron/cron.module";
import { middlewareModule } from "./modules/middleware/middleware.module";
import { notificationsModule } from "./modules/notifications/notifications.module";
import { pagesModule } from "./modules/pages/pages.module";
+import { paymentsModule } from "./modules/payments/payments.module";
import { queueModule } from "./modules/queue/queue.module";
import { searchModule } from "./modules/search/search.module";
import { usersModule } from "./modules/users/users.module";
@@ -47,6 +48,7 @@ export const newBuildPluginApiCore = buildApiPlugin({
contentModule,
cronModule,
pagesModule,
+ paymentsModule,
queueModule,
searchModule,
],
@@ -98,6 +100,10 @@ export const newBuildPluginApiCore = buildApiPlugin({
{ permission: "can_delete", dependsOn: ["can_view"] },
],
queue: ["can_view"],
+ payments: [
+ "can_view",
+ { permission: "can_retry", dependsOn: ["can_view"] },
+ ],
notifications: [
"can_view",
{ permission: "can_edit", dependsOn: ["can_view"] },
diff --git a/packages/vitnode/src/database/payments.ts b/packages/vitnode/src/database/payments.ts
new file mode 100644
index 0000000000..a3818db59a
--- /dev/null
+++ b/packages/vitnode/src/database/payments.ts
@@ -0,0 +1,405 @@
+import { sql } from "drizzle-orm";
+import { camelCase, index, uniqueIndex } from "drizzle-orm/pg-core";
+
+import {
+ BILLING_INTERVALS,
+ CHECKOUT_STATUSES,
+ DISPUTE_STATUSES,
+ FULFILLMENT_STATUSES,
+ INVOICE_STATUSES,
+ OFFER_MODES,
+ PURCHASE_PAYMENT_STATUSES,
+ REFUND_STATUSES,
+ SUBSCRIPTION_STATUSES,
+} from "@/payments/status";
+
+import { core_users } from "./users";
+
+/**
+ * Billing state. Amounts are `bigint` columns read as JS numbers: every write
+ * goes through `assertMinorUnits`, which keeps them inside
+ * `Number.MAX_SAFE_INTEGER`, so they serialise as plain JSON numbers.
+ *
+ * Every provider reference is unique together with its provider and scope
+ * (`test`/`live`), so ids from two environments can never collide. No card
+ * details are stored anywhere.
+ */
+
+/** Which provider customer stands for which user. */
+export const core_payments_customers = camelCase.table.withRLS(
+ "core_payments_customers",
+ t => ({
+ id: t.serial().primaryKey(),
+ userId: t
+ .integer()
+ .references(() => core_users.id, { onDelete: "cascade" })
+ .notNull(),
+ provider: t.varchar({ length: 32 }).notNull(),
+ providerScope: t.varchar({ length: 32 }).notNull(),
+ externalId: t.varchar({ length: 255 }).notNull(),
+ createdAt: t.timestamp().notNull().defaultNow(),
+ }),
+ t => [
+ uniqueIndex("core_payments_customers_user_unique").on(
+ t.provider,
+ t.providerScope,
+ t.userId,
+ ),
+ uniqueIndex("core_payments_customers_external_unique").on(
+ t.provider,
+ t.providerScope,
+ t.externalId,
+ ),
+ ],
+);
+
+/**
+ * One thing a user set out to buy, with a snapshot of the offer as it was.
+ * Removing or repricing the offer later never changes this row.
+ */
+export const core_payments_purchases = camelCase.table.withRLS(
+ "core_payments_purchases",
+ t => ({
+ id: t.serial().primaryKey(),
+ publicId: t.uuid().notNull().defaultRandom(),
+ // `set null`, not `cascade`: a billing record outlives the account it was
+ // charged to.
+ userId: t
+ .integer()
+ .references(() => core_users.id, { onDelete: "set null" }),
+ pluginId: t.varchar({ length: 100 }).notNull(),
+ offerId: t.varchar({ length: 64 }).notNull(),
+ offerName: t.varchar({ length: 255 }).notNull(),
+ mode: t.varchar({ enum: OFFER_MODES, length: 20 }).notNull(),
+ interval: t.varchar({ enum: BILLING_INTERVALS, length: 10 }),
+ amount: t.bigint({ mode: "number" }).notNull(),
+ currency: t.varchar({ length: 3 }).notNull(),
+ provider: t.varchar({ length: 32 }).notNull(),
+ providerScope: t.varchar({ length: 32 }).notNull(),
+ externalCustomerId: t.varchar({ length: 255 }),
+ /** The one-time payment the checkout produced. */
+ externalPaymentId: t.varchar({ length: 255 }),
+ paymentStatus: t
+ .varchar({ enum: PURCHASE_PAYMENT_STATUSES, length: 20 })
+ .notNull()
+ .default("awaiting_payment"),
+ fulfillmentStatus: t
+ .varchar({ enum: FULFILLMENT_STATUSES, length: 20 })
+ .notNull()
+ .default("not_started"),
+ refundStatus: t
+ .varchar({ enum: REFUND_STATUSES, length: 10 })
+ .notNull()
+ .default("none"),
+ refundedAmount: t.bigint({ mode: "number" }).notNull().default(0),
+ disputeStatus: t.varchar({ enum: DISPUTE_STATUSES, length: 10 }),
+ /** Client-supplied, scoped to the buyer. */
+ idempotencyKey: t.varchar({ length: 100 }),
+ /** Hash of the resolved operation, to refuse a reused key. */
+ requestHash: t.varchar({ length: 64 }).notNull(),
+ lastError: t.text(),
+ /** When reconciliation should next ask the provider about this one. */
+ nextCheckAt: t.timestamp(),
+ /** Last provider read asked for by the buyer's browser - a throttle. */
+ refreshCheckedAt: t.timestamp(),
+ paidAt: t.timestamp(),
+ createdAt: t.timestamp().notNull().defaultNow(),
+ updatedAt: t
+ .timestamp()
+ .notNull()
+ .defaultNow()
+ .$onUpdate(() => new Date()),
+ }),
+ t => [
+ uniqueIndex("core_payments_purchases_public_id_unique").on(t.publicId),
+ uniqueIndex("core_payments_purchases_idempotency_unique")
+ .on(t.userId, t.idempotencyKey)
+ .where(sql`"idempotencyKey" IS NOT NULL`),
+ // At most one unfinished purchase per buyer and offer. The checkout path
+ // also takes a lock, this is what holds when everything else races.
+ uniqueIndex("core_payments_purchases_open_unique")
+ .on(t.userId, t.pluginId, t.offerId)
+ .where(sql`"paymentStatus" IN ('awaiting_payment', 'processing')`),
+ uniqueIndex("core_payments_purchases_payment_unique")
+ .on(t.provider, t.providerScope, t.externalPaymentId)
+ .where(sql`"externalPaymentId" IS NOT NULL`),
+ index("core_payments_purchases_user_created_idx").on(t.userId, t.createdAt),
+ index("core_payments_purchases_created_idx").on(t.createdAt, t.id),
+ index("core_payments_purchases_reconcile_idx")
+ .on(t.nextCheckAt)
+ .where(sql`"paymentStatus" IN ('awaiting_payment', 'processing')`),
+ ],
+);
+
+/** One hosted checkout for a purchase. A failed creation can be retried. */
+export const core_payments_checkouts = camelCase.table.withRLS(
+ "core_payments_checkouts",
+ t => ({
+ id: t.serial().primaryKey(),
+ purchaseId: t
+ .integer()
+ .references(() => core_payments_purchases.id, { onDelete: "cascade" })
+ .notNull(),
+ provider: t.varchar({ length: 32 }).notNull(),
+ providerScope: t.varchar({ length: 32 }).notNull(),
+ externalId: t.varchar({ length: 255 }),
+ url: t.text(),
+ status: t
+ .varchar({ enum: CHECKOUT_STATUSES, length: 20 })
+ .notNull()
+ .default("creating"),
+ /** Sent to the provider, so a retried create returns the same checkout. */
+ idempotencyKey: t.varchar({ length: 100 }).notNull(),
+ expiresAt: t.timestamp().notNull(),
+ lastError: t.text(),
+ createdAt: t.timestamp().notNull().defaultNow(),
+ updatedAt: t
+ .timestamp()
+ .notNull()
+ .defaultNow()
+ .$onUpdate(() => new Date()),
+ }),
+ t => [
+ uniqueIndex("core_payments_checkouts_idempotency_unique").on(
+ t.idempotencyKey,
+ ),
+ uniqueIndex("core_payments_checkouts_external_unique")
+ .on(t.provider, t.providerScope, t.externalId)
+ .where(sql`"externalId" IS NOT NULL`),
+ index("core_payments_checkouts_purchase_idx").on(t.purchaseId, t.id),
+ ],
+);
+
+export const core_payments_subscriptions = camelCase.table.withRLS(
+ "core_payments_subscriptions",
+ t => ({
+ id: t.serial().primaryKey(),
+ publicId: t.uuid().notNull().defaultRandom(),
+ userId: t
+ .integer()
+ .references(() => core_users.id, { onDelete: "set null" }),
+ /** The purchase that started it - and its offer snapshot. */
+ purchaseId: t
+ .integer()
+ .references(() => core_payments_purchases.id, { onDelete: "restrict" })
+ .notNull(),
+ pluginId: t.varchar({ length: 100 }).notNull(),
+ offerId: t.varchar({ length: 64 }).notNull(),
+ offerName: t.varchar({ length: 255 }).notNull(),
+ interval: t.varchar({ enum: BILLING_INTERVALS, length: 10 }).notNull(),
+ /** What this subscriber pays, as the provider holds it. */
+ amount: t.bigint({ mode: "number" }).notNull(),
+ currency: t.varchar({ length: 3 }).notNull(),
+ provider: t.varchar({ length: 32 }).notNull(),
+ providerScope: t.varchar({ length: 32 }).notNull(),
+ externalId: t.varchar({ length: 255 }).notNull(),
+ externalCustomerId: t.varchar({ length: 255 }).notNull(),
+ externalPriceId: t.varchar({ length: 255 }),
+ status: t.varchar({ enum: SUBSCRIPTION_STATUSES, length: 20 }).notNull(),
+ providerStatus: t.varchar({ length: 50 }).notNull(),
+ /** End of the latest period a verified paid invoice covers. */
+ paidThrough: t.timestamp(),
+ currentPeriodEnd: t.timestamp(),
+ cancelAtPeriodEnd: t.boolean().notNull().default(false),
+ cancelAt: t.timestamp(),
+ canceledAt: t.timestamp(),
+ endedAt: t.timestamp(),
+ syncedAt: t.timestamp().notNull().defaultNow(),
+ nextCheckAt: t.timestamp(),
+ lastError: t.text(),
+ createdAt: t.timestamp().notNull().defaultNow(),
+ updatedAt: t
+ .timestamp()
+ .notNull()
+ .defaultNow()
+ .$onUpdate(() => new Date()),
+ }),
+ t => [
+ uniqueIndex("core_payments_subscriptions_public_id_unique").on(t.publicId),
+ uniqueIndex("core_payments_subscriptions_purchase_unique").on(t.purchaseId),
+ uniqueIndex("core_payments_subscriptions_external_unique").on(
+ t.provider,
+ t.providerScope,
+ t.externalId,
+ ),
+ index("core_payments_subscriptions_user_created_idx").on(
+ t.userId,
+ t.createdAt,
+ ),
+ index("core_payments_subscriptions_created_idx").on(t.createdAt, t.id),
+ index("core_payments_subscriptions_reconcile_idx")
+ .on(t.nextCheckAt)
+ .where(sql`"nextCheckAt" IS NOT NULL`),
+ ],
+);
+
+/** One recurring charge - initial or renewal - of a subscription. */
+export const core_payments_invoices = camelCase.table.withRLS(
+ "core_payments_invoices",
+ t => ({
+ id: t.serial().primaryKey(),
+ subscriptionId: t
+ .integer()
+ .references(() => core_payments_subscriptions.id, { onDelete: "cascade" })
+ .notNull(),
+ provider: t.varchar({ length: 32 }).notNull(),
+ providerScope: t.varchar({ length: 32 }).notNull(),
+ externalId: t.varchar({ length: 255 }).notNull(),
+ externalPaymentId: t.varchar({ length: 255 }),
+ status: t.varchar({ enum: INVOICE_STATUSES, length: 20 }).notNull(),
+ providerStatus: t.varchar({ length: 50 }).notNull(),
+ billingReason: t.varchar({ length: 50 }),
+ amountDue: t.bigint({ mode: "number" }).notNull(),
+ amountPaid: t.bigint({ mode: "number" }).notNull().default(0),
+ currency: t.varchar({ length: 3 }).notNull(),
+ periodStart: t.timestamp(),
+ periodEnd: t.timestamp(),
+ paidAt: t.timestamp(),
+ refundStatus: t
+ .varchar({ enum: REFUND_STATUSES, length: 10 })
+ .notNull()
+ .default("none"),
+ refundedAmount: t.bigint({ mode: "number" }).notNull().default(0),
+ disputeStatus: t.varchar({ enum: DISPUTE_STATUSES, length: 10 }),
+ createdAt: t.timestamp().notNull().defaultNow(),
+ updatedAt: t
+ .timestamp()
+ .notNull()
+ .defaultNow()
+ .$onUpdate(() => new Date()),
+ }),
+ t => [
+ uniqueIndex("core_payments_invoices_external_unique").on(
+ t.provider,
+ t.providerScope,
+ t.externalId,
+ ),
+ uniqueIndex("core_payments_invoices_payment_unique")
+ .on(t.provider, t.providerScope, t.externalPaymentId)
+ .where(sql`"externalPaymentId" IS NOT NULL`),
+ index("core_payments_invoices_subscription_idx").on(
+ t.subscriptionId,
+ t.createdAt,
+ ),
+ ],
+);
+
+/**
+ * Refunds and disputes, each attributed to the one-time purchase or the
+ * recurring invoice it belongs to. The original paid amount is never touched.
+ */
+export const core_payments_adjustments = camelCase.table.withRLS(
+ "core_payments_adjustments",
+ t => ({
+ id: t.serial().primaryKey(),
+ kind: t.varchar({ enum: ["refund", "dispute"], length: 10 }).notNull(),
+ provider: t.varchar({ length: 32 }).notNull(),
+ providerScope: t.varchar({ length: 32 }).notNull(),
+ externalId: t.varchar({ length: 255 }).notNull(),
+ purchaseId: t
+ .integer()
+ .references(() => core_payments_purchases.id, { onDelete: "cascade" }),
+ invoiceId: t
+ .integer()
+ .references(() => core_payments_invoices.id, { onDelete: "cascade" }),
+ amount: t.bigint({ mode: "number" }).notNull(),
+ currency: t.varchar({ length: 3 }).notNull(),
+ status: t.varchar({ length: 30 }).notNull(),
+ reason: t.varchar({ length: 100 }),
+ createdAt: t.timestamp().notNull().defaultNow(),
+ updatedAt: t
+ .timestamp()
+ .notNull()
+ .defaultNow()
+ .$onUpdate(() => new Date()),
+ }),
+ t => [
+ uniqueIndex("core_payments_adjustments_external_unique").on(
+ t.provider,
+ t.providerScope,
+ t.kind,
+ t.externalId,
+ ),
+ index("core_payments_adjustments_purchase_idx").on(t.purchaseId),
+ index("core_payments_adjustments_invoice_idx").on(t.invoiceId),
+ ],
+);
+
+/**
+ * The webhook inbox. A row exists once an event was verified and accepted;
+ * processing happens in the queue. Only the normalized target is stored - the
+ * raw provider payload is never kept.
+ */
+export const core_payments_webhook_events = camelCase.table.withRLS(
+ "core_payments_webhook_events",
+ t => ({
+ id: t.serial().primaryKey(),
+ provider: t.varchar({ length: 32 }).notNull(),
+ providerScope: t.varchar({ length: 32 }).notNull(),
+ externalId: t.varchar({ length: 255 }).notNull(),
+ type: t.varchar({ length: 100 }).notNull(),
+ target: t.jsonb().$type>().notNull(),
+ status: t
+ .varchar({ enum: ["pending", "processed", "failed"], length: 20 })
+ .notNull()
+ .default("pending"),
+ attempts: t.integer().notNull().default(0),
+ lastError: t.text(),
+ receivedAt: t.timestamp().notNull().defaultNow(),
+ processedAt: t.timestamp(),
+ }),
+ t => [
+ uniqueIndex("core_payments_webhook_events_external_unique").on(
+ t.provider,
+ t.providerScope,
+ t.externalId,
+ ),
+ index("core_payments_webhook_events_status_idx").on(t.status, t.receivedAt),
+ ],
+);
+
+/**
+ * One business effect of a purchase - "grant", "refund:",
+ * "subscription". Unique per purchase, so a retried job finds the row it
+ * already completed instead of granting twice. A subscription's row is reused:
+ * `generation` moves on every change and the handler runs until
+ * `completedGeneration` catches up.
+ */
+export const core_payments_fulfillments = camelCase.table.withRLS(
+ "core_payments_fulfillments",
+ t => ({
+ id: t.serial().primaryKey(),
+ purchaseId: t
+ .integer()
+ .references(() => core_payments_purchases.id, { onDelete: "cascade" })
+ .notNull(),
+ effect: t.varchar({ length: 100 }).notNull(),
+ payload: t
+ .jsonb()
+ .$type>()
+ .notNull()
+ .default({}),
+ status: t
+ .varchar({ enum: ["pending", "completed", "failed"], length: 20 })
+ .notNull()
+ .default("pending"),
+ generation: t.integer().notNull().default(1),
+ completedGeneration: t.integer().notNull().default(0),
+ attempts: t.integer().notNull().default(0),
+ lastError: t.text(),
+ createdAt: t.timestamp().notNull().defaultNow(),
+ updatedAt: t
+ .timestamp()
+ .notNull()
+ .defaultNow()
+ .$onUpdate(() => new Date()),
+ completedAt: t.timestamp(),
+ }),
+ t => [
+ uniqueIndex("core_payments_fulfillments_effect_unique").on(
+ t.purchaseId,
+ t.effect,
+ ),
+ index("core_payments_fulfillments_status_idx").on(t.status, t.updatedAt),
+ ],
+);
diff --git a/packages/vitnode/src/database/relations.ts b/packages/vitnode/src/database/relations.ts
index c92c79eb4c..fc9599abda 100644
--- a/packages/vitnode/src/database/relations.ts
+++ b/packages/vitnode/src/database/relations.ts
@@ -12,6 +12,7 @@ import * as moderators from "./moderators";
import * as navigation from "./navigation";
import * as notifications from "./notifications";
import * as passkeys from "./passkeys";
+import * as payments from "./payments";
import * as queue from "./queue";
import * as roles from "./roles";
import * as search from "./search";
@@ -32,6 +33,7 @@ export const coreSchema = {
...navigation,
...notifications,
...passkeys,
+ ...payments,
...queue,
...roles,
...search,
@@ -175,4 +177,46 @@ export const coreRelations = defineRelations(coreSchema, r => ({
to: r.core_users.id,
}),
},
+
+ core_payments_purchases: {
+ user: r.one.core_users({
+ from: r.core_payments_purchases.userId,
+ to: r.core_users.id,
+ }),
+ checkouts: r.many.core_payments_checkouts(),
+ fulfillments: r.many.core_payments_fulfillments(),
+ },
+
+ core_payments_checkouts: {
+ purchase: r.one.core_payments_purchases({
+ from: r.core_payments_checkouts.purchaseId,
+ to: r.core_payments_purchases.id,
+ }),
+ },
+
+ core_payments_fulfillments: {
+ purchase: r.one.core_payments_purchases({
+ from: r.core_payments_fulfillments.purchaseId,
+ to: r.core_payments_purchases.id,
+ }),
+ },
+
+ core_payments_subscriptions: {
+ user: r.one.core_users({
+ from: r.core_payments_subscriptions.userId,
+ to: r.core_users.id,
+ }),
+ purchase: r.one.core_payments_purchases({
+ from: r.core_payments_subscriptions.purchaseId,
+ to: r.core_payments_purchases.id,
+ }),
+ invoices: r.many.core_payments_invoices(),
+ },
+
+ core_payments_invoices: {
+ subscription: r.one.core_payments_subscriptions({
+ from: r.core_payments_invoices.subscriptionId,
+ to: r.core_payments_subscriptions.id,
+ }),
+ },
}));
diff --git a/packages/vitnode/src/locales/en.json b/packages/vitnode/src/locales/en.json
index 31cf431fc7..4cb90f9c2b 100644
--- a/packages/vitnode/src/locales/en.json
+++ b/packages/vitnode/src/locales/en.json
@@ -40,6 +40,9 @@
"@vitnode/core:ai:can_manage": "Manage AI settings, models, pricing and access",
"@vitnode/core:queue": "Queue Tasks",
"@vitnode/core:queue:can_view": "View queue tasks",
+ "@vitnode/core:payments": "Payments",
+ "@vitnode/core:payments:can_view": "View purchases, subscriptions and webhook events",
+ "@vitnode/core:payments:can_retry": "Retry failed payment processing",
"@vitnode/core:notifications": "Notifications",
"@vitnode/core:notifications:can_view": "View notification settings and delivery health",
"@vitnode/core:notifications:can_edit": "Edit notification settings",
@@ -939,6 +942,7 @@
"security": "Security",
"sso": "Connected accounts",
"notifications": "Notifications",
+ "billing": "Billing",
"ai": "AI usage"
},
"overview": {
@@ -1934,6 +1938,95 @@
"empty_title": "Nothing here yet",
"empty_unread_title": "No unread notifications",
"empty_desc": "When something needs your attention, it lands here."
+ },
+ "payments": {
+ "interval": {
+ "month": "Monthly",
+ "year": "Yearly"
+ },
+ "per_interval": {
+ "month": "{price} / month",
+ "year": "{price} / year"
+ },
+ "purchase_state": {
+ "awaiting_payment": "Awaiting payment",
+ "processing": "Processing payment",
+ "paid": "Paid",
+ "failed": "Not completed",
+ "fulfillment_pending": "Paid, being delivered",
+ "fulfillment_failed": "Paid, delivery delayed",
+ "fulfilled": "Paid",
+ "refunded": "Refunded",
+ "partially_refunded": "Partially refunded"
+ },
+ "subscription_state": {
+ "active": "Active",
+ "needs_attention": "Renewal needs attention",
+ "cancellation_scheduled": "Cancellation scheduled",
+ "ended": "Ended",
+ "incomplete": "Waiting for the first payment"
+ },
+ "disputed": "Disputed",
+ "billing": {
+ "title": "Billing",
+ "desc": "Everything you bought on this site, and the subscriptions you pay for.",
+ "manage": "Manage billing",
+ "managing": "Opening billing management…",
+ "portal_error": "Billing management could not be opened",
+ "tax_note": "Receipts come from the payment provider. This site does not issue tax invoices.",
+ "purchases": {
+ "title": "Purchases",
+ "empty": "No purchases yet. When you buy something, it shows up here.",
+ "item": "Item",
+ "amount": "Amount",
+ "date": "Date",
+ "status": "Status",
+ "refunded": "{amount} refunded",
+ "more": "Show more",
+ "error": "Your purchases could not be loaded."
+ },
+ "subscriptions": {
+ "title": "Subscriptions",
+ "empty": "No subscriptions yet.",
+ "paid_through": "Paid through {date}",
+ "renews": "Renews on {date}",
+ "ends": "Ends on {date}",
+ "ended": "Ended on {date}",
+ "attention": "The last renewal payment did not go through. Update your payment method to keep this subscription.",
+ "more": "Show more",
+ "error": "Your subscriptions could not be loaded."
+ }
+ },
+ "checkout": {
+ "buy": "Buy now",
+ "subscribe": "Subscribe",
+ "redirecting": "Opening secure checkout…",
+ "currency": "Currency",
+ "interval": "Billing period",
+ "continue": "Continue checkout",
+ "cancel": "Cancel checkout",
+ "canceled": "Checkout canceled",
+ "canceled_desc": "You can start again whenever you like.",
+ "error": "Checkout could not start",
+ "retry_hint": "Nothing was charged. Try again in a moment.",
+ "unavailable": "Not available in your currency yet."
+ },
+ "return": {
+ "checking": "Confirming your payment…",
+ "processing": "Your payment is processing. Some payment methods take a few minutes.",
+ "paid": "Payment confirmed. Unlocking your purchase…",
+ "fulfilled": "Payment confirmed. Enjoy!",
+ "fulfillment_failed": "Payment confirmed. Unlocking is delayed - we will finish it automatically.",
+ "failed": "The payment did not complete. You have not been charged.",
+ "canceled": "Checkout canceled. You have not been charged.",
+ "slow": "This is taking longer than usual. Your payment is safe - check again in a moment.",
+ "check_again": "Check again",
+ "error": "We could not check this payment right now."
+ },
+ "disabled": {
+ "title": "Payments are not enabled",
+ "desc": "This site has no payment provider configured yet. An administrator can enable Payments in the API configuration - the developer documentation explains how."
+ }
}
},
"admin": {
@@ -2059,6 +2152,12 @@
"queue": "Queue Tasks",
"search": "Search"
},
+ "payments": {
+ "title": "Payments",
+ "overview": "Overview",
+ "subscriptions": "Subscriptions",
+ "events": "Webhook events"
+ },
"ai": {
"title": "Artificial Intelligence (AI)",
"overview": "Overview",
@@ -3218,6 +3317,143 @@
"bottomBarDesc": "Up to three links at the bottom of the screen on phones, plus a Menu tab with the rest of the header menu."
}
},
+ "payments": {
+ "title": "Payments",
+ "desc": "Purchases, payment processing and fulfillment across every plugin.",
+ "overview": {
+ "totals": "Collected",
+ "totals_desc": "Totals are kept per currency and never added together.",
+ "one_time": "One-time",
+ "recurring": "Recurring",
+ "refunded": "Refunded",
+ "no_totals": "Nothing collected yet.",
+ "attention": "Needs attention",
+ "fulfillments_failed": "Failed fulfillments",
+ "fulfillments_pending": "Fulfillments in progress",
+ "events_failed": "Failed webhook events",
+ "processing": "Payments processing",
+ "disputes": "Open disputes",
+ "subscriptions_attention": "Subscriptions needing attention",
+ "worker": "Background worker",
+ "worker_ok": "The queue worker is running.",
+ "worker_stale": "The cron worker has not run recently. Accepted payment events wait until it does.",
+ "worker_lagging": "{count} payment jobs are waiting for the worker.",
+ "worker_no_adapter": "No cron adapter in this API. Call the cron endpoint from a scheduler, or set one up - see the cron docs.",
+ "last_run": "Last run {date}",
+ "never_run": "Never ran",
+ "providers": "Providers",
+ "scope": "{scope} mode"
+ },
+ "purchases": {
+ "title": "Purchases",
+ "offer": "Offer",
+ "buyer": "Buyer",
+ "amount": "Amount",
+ "status": "Payment",
+ "fulfillment": "Fulfillment",
+ "provider": "Provider",
+ "created": "Created",
+ "deleted_user": "Deleted user"
+ },
+ "subscriptions": {
+ "title": "Subscriptions",
+ "desc": "Every subscription and the period its last verified payment covers.",
+ "plan": "Plan",
+ "subscriber": "Subscriber",
+ "price": "Price",
+ "status": "Status",
+ "paid_through": "Paid through",
+ "created": "Created"
+ },
+ "events": {
+ "title": "Webhook events",
+ "desc": "Verified provider events and whether processing them worked. Raw payloads are never stored.",
+ "type": "Event",
+ "target": "Object",
+ "status": "Status",
+ "attempts": "Attempts",
+ "received": "Received",
+ "error": "Last error",
+ "retry": "Retry",
+ "retried": "Event queued again",
+ "retried_desc": "Processing re-reads the provider, so retrying is safe.",
+ "retry_error": "The event could not be queued"
+ },
+ "detail": {
+ "title": "Purchase",
+ "back": "Back to purchases",
+ "summary": "Summary",
+ "offer_missing": "This offer is no longer registered. History stays readable, but new fulfillment work cannot run until the plugin is back.",
+ "checkouts": "Checkout attempts",
+ "fulfillments": "Fulfillment",
+ "effect": "Effect",
+ "attempts": "Attempts",
+ "retry": "Retry",
+ "retried": "Fulfillment queued again",
+ "retried_desc": "It runs through the queue and never grants twice.",
+ "retry_error": "The fulfillment could not be queued",
+ "adjustments": "Refunds and disputes",
+ "invoices": "Billing history",
+ "period": "Period",
+ "subscription": "Subscription",
+ "references": "Provider references",
+ "none": "None",
+ "last_error": "Last error",
+ "refund": "Refund",
+ "dispute": "Dispute"
+ },
+ "filters": {
+ "status": "Status",
+ "fulfillment": "Fulfillment",
+ "currency": "Currency",
+ "provider": "Provider",
+ "period": "Created",
+ "periods": {
+ "7d": "Last 7 days",
+ "30d": "Last 30 days",
+ "90d": "Last 90 days",
+ "365d": "Last year"
+ }
+ },
+ "payment_status": {
+ "awaiting_payment": "Awaiting payment",
+ "processing": "Processing",
+ "paid": "Paid",
+ "failed": "Failed",
+ "expired": "Expired",
+ "canceled": "Canceled"
+ },
+ "fulfillment_status": {
+ "not_started": "Not started",
+ "pending": "Pending",
+ "fulfilled": "Fulfilled",
+ "failed": "Failed"
+ },
+ "refund_status": {
+ "none": "Not refunded",
+ "partial": "Partially refunded",
+ "full": "Refunded"
+ },
+ "subscription_status": {
+ "incomplete": "Incomplete",
+ "active": "Active",
+ "past_due": "Past due",
+ "unpaid": "Unpaid",
+ "paused": "Paused",
+ "canceled": "Canceled",
+ "incomplete_expired": "Expired before payment"
+ },
+ "event_status": {
+ "pending": "Pending",
+ "processed": "Processed",
+ "failed": "Failed"
+ },
+ "work_status": {
+ "pending": "Pending",
+ "completed": "Completed",
+ "failed": "Failed"
+ }
+ },
"ai": {
"cost": {
"unknown": "Unknown",
diff --git a/packages/vitnode/src/pages/admin/payments/events.tsx b/packages/vitnode/src/pages/admin/payments/events.tsx
new file mode 100644
index 0000000000..d8eb4fa1a0
--- /dev/null
+++ b/packages/vitnode/src/pages/admin/payments/events.tsx
@@ -0,0 +1,42 @@
+import type { PluginRoutePageProps } from "@/routing";
+import type { AdminWebhookEventsRouteData } from "@/tanstack/admin/payments/route";
+import type { AdminTableRouteSearch } from "@/tanstack/admin/table-search";
+
+import { adminBreadcrumb } from "@/tanstack/admin/breadcrumb";
+import { loadAdminWebhookEventsRoute } from "@/tanstack/admin/payments/route";
+import { eventsRouteParams } from "@/tanstack/admin/payments/route-search";
+import { AdminWebhookEventsRouteContent } from "@/tanstack/admin/payments/screen";
+import { defineAdminRoute } from "@/tanstack/plugin-routes";
+
+const AdminWebhookEventsPage = ({
+ loaderData,
+ navigate,
+ search,
+}: PluginRoutePageProps<
+ AdminWebhookEventsRouteData,
+ AdminTableRouteSearch
+>) => (
+ }
+ />
+);
+
+export const route = defineAdminRoute<
+ AdminWebhookEventsRouteData,
+ AdminTableRouteSearch
+>({
+ // `head` after `load`, always.
+ load: async ({ context, search, t }) =>
+ await loadAdminWebhookEventsRoute({
+ ...context,
+ params: eventsRouteParams(search as Record),
+ t,
+ }),
+ head: ({ loaderData }) => ({ ...loaderData }),
+
+ breadcrumb: adminBreadcrumb({ segments: ["core", "payments", "events"] }),
+});
+
+export default AdminWebhookEventsPage;
diff --git a/packages/vitnode/src/pages/admin/payments/index.tsx b/packages/vitnode/src/pages/admin/payments/index.tsx
new file mode 100644
index 0000000000..4321a09bde
--- /dev/null
+++ b/packages/vitnode/src/pages/admin/payments/index.tsx
@@ -0,0 +1,39 @@
+import type { PluginRoutePageProps } from "@/routing";
+import type { AdminPaymentsRouteData } from "@/tanstack/admin/payments/route";
+import type { PaymentsTableRouteSearch } from "@/tanstack/admin/payments/route-search";
+
+import { adminBreadcrumb } from "@/tanstack/admin/breadcrumb";
+import { loadAdminPaymentsRoute } from "@/tanstack/admin/payments/route";
+import { purchasesRouteParams } from "@/tanstack/admin/payments/route-search";
+import { AdminPaymentsRouteContent } from "@/tanstack/admin/payments/screen";
+import { defineAdminRoute } from "@/tanstack/plugin-routes";
+
+const AdminPaymentsPage = ({
+ loaderData,
+ navigate,
+ search,
+}: PluginRoutePageProps) => (
+ }
+ />
+);
+
+export const route = defineAdminRoute<
+ AdminPaymentsRouteData,
+ PaymentsTableRouteSearch
+>({
+ // `head` after `load`, always.
+ load: async ({ context, search, t }) =>
+ await loadAdminPaymentsRoute({
+ ...context,
+ params: purchasesRouteParams(search as Record),
+ t,
+ }),
+ head: ({ loaderData }) => ({ ...loaderData }),
+
+ breadcrumb: adminBreadcrumb({ segments: ["core", "payments"] }),
+});
+
+export default AdminPaymentsPage;
diff --git a/packages/vitnode/src/pages/admin/payments/purchase.tsx b/packages/vitnode/src/pages/admin/payments/purchase.tsx
new file mode 100644
index 0000000000..cda9ce387c
--- /dev/null
+++ b/packages/vitnode/src/pages/admin/payments/purchase.tsx
@@ -0,0 +1,24 @@
+import type { PluginRoutePageProps } from "@/routing";
+import type { AdminPaymentRouteData } from "@/tanstack/admin/payments/route";
+
+import { adminBreadcrumb } from "@/tanstack/admin/breadcrumb";
+import { loadAdminPaymentRoute } from "@/tanstack/admin/payments/route";
+import { AdminPaymentRouteContent } from "@/tanstack/admin/payments/screen";
+import { defineAdminRoute } from "@/tanstack/plugin-routes";
+
+const AdminPaymentPage = ({
+ loaderData,
+}: PluginRoutePageProps) => (
+
+);
+
+export const route = defineAdminRoute({
+ // `head` after `load`, always.
+ load: async ({ context, params, t }) =>
+ await loadAdminPaymentRoute({ ...context, params: { id: params.id }, t }),
+ head: ({ loaderData }) => ({ ...loaderData }),
+
+ breadcrumb: adminBreadcrumb({ segments: ["core", "payments"] }),
+});
+
+export default AdminPaymentPage;
diff --git a/packages/vitnode/src/pages/admin/payments/subscriptions.tsx b/packages/vitnode/src/pages/admin/payments/subscriptions.tsx
new file mode 100644
index 0000000000..f446732dec
--- /dev/null
+++ b/packages/vitnode/src/pages/admin/payments/subscriptions.tsx
@@ -0,0 +1,44 @@
+import type { PluginRoutePageProps } from "@/routing";
+import type { AdminSubscriptionsRouteData } from "@/tanstack/admin/payments/route";
+import type { PaymentsTableRouteSearch } from "@/tanstack/admin/payments/route-search";
+
+import { adminBreadcrumb } from "@/tanstack/admin/breadcrumb";
+import { loadAdminSubscriptionsRoute } from "@/tanstack/admin/payments/route";
+import { subscriptionsRouteParams } from "@/tanstack/admin/payments/route-search";
+import { AdminSubscriptionsRouteContent } from "@/tanstack/admin/payments/screen";
+import { defineAdminRoute } from "@/tanstack/plugin-routes";
+
+const AdminSubscriptionsPage = ({
+ loaderData,
+ navigate,
+ search,
+}: PluginRoutePageProps<
+ AdminSubscriptionsRouteData,
+ PaymentsTableRouteSearch
+>) => (
+ }
+ />
+);
+
+export const route = defineAdminRoute<
+ AdminSubscriptionsRouteData,
+ PaymentsTableRouteSearch
+>({
+ // `head` after `load`, always.
+ load: async ({ context, search, t }) =>
+ await loadAdminSubscriptionsRoute({
+ ...context,
+ params: subscriptionsRouteParams(search as Record),
+ t,
+ }),
+ head: ({ loaderData }) => ({ ...loaderData }),
+
+ breadcrumb: adminBreadcrumb({
+ segments: ["core", "payments", "subscriptions"],
+ }),
+});
+
+export default AdminSubscriptionsPage;
diff --git a/packages/vitnode/src/pages/settings/billing.tsx b/packages/vitnode/src/pages/settings/billing.tsx
new file mode 100644
index 0000000000..12442a5724
--- /dev/null
+++ b/packages/vitnode/src/pages/settings/billing.tsx
@@ -0,0 +1,29 @@
+import type { PluginRoutePageProps } from "@/routing";
+
+import { loadBillingRoute } from "@/tanstack/payments/billing-route";
+import { defineAuthenticatedRoute } from "@/tanstack/plugin-routes";
+import { settingsBreadcrumb } from "@/tanstack/settings/breadcrumb";
+import { BillingContent } from "@/views/payments/billing/billing-content";
+
+interface BillingData {
+ userId: number;
+}
+
+const BillingPage = ({ loaderData }: PluginRoutePageProps) => (
+
+);
+
+export const route = defineAuthenticatedRoute({
+ load: async ({ context }) =>
+ await loadBillingRoute({
+ queryClient: context.queryClient,
+ userId: context.auth.user.id,
+ }),
+ head: ({ t }) => ({
+ title: `${t("core.auth.settings.nav.billing")} - ${t("core.auth.settings.title")}`,
+ }),
+
+ breadcrumb: settingsBreadcrumb("billing"),
+});
+
+export default BillingPage;
diff --git a/packages/vitnode/src/payments/boundaries.test.ts b/packages/vitnode/src/payments/boundaries.test.ts
new file mode 100644
index 0000000000..35bdbcdad9
--- /dev/null
+++ b/packages/vitnode/src/payments/boundaries.test.ts
@@ -0,0 +1,57 @@
+// @vitest-environment node
+import { readdirSync, readFileSync, statSync } from "node:fs";
+import { dirname, join } from "node:path";
+import { fileURLToPath } from "node:url";
+import { describe, expect, it } from "vitest";
+
+import { offenders, SRC_ROOT } from "@/tests/import-graph";
+
+const here = dirname(fileURLToPath(import.meta.url));
+
+/** What a browser bundle must never pull in through the payments entry point. */
+const SERVER_ONLY = [
+ "drizzle-orm",
+ "hono",
+ "node:crypto",
+ "postgres",
+ "stripe",
+ "@/vitnode.config",
+];
+
+const sourcesUnder = (directory: string): string[] =>
+ readdirSync(directory).flatMap(name => {
+ const path = join(directory, name);
+ if (statSync(path).isDirectory()) {
+ return name === "node_modules" || name === "dist"
+ ? []
+ : sourcesUnder(path);
+ }
+
+ return /\.tsx?$/.test(name) ? [path] : [];
+ });
+
+describe("the payments entry point is browser-safe", () => {
+ it("is checked by a walk that does see server modules", () => {
+ // The control: the checkout service is server code, so a walk that reports
+ // nothing for it would make the assertion below meaningless.
+ expect(
+ offenders(join(SRC_ROOT, "api/models/payments/checkout.ts"), SERVER_ONLY),
+ ).not.toEqual([]);
+ });
+
+ it("reaches no server-only module", () => {
+ expect(offenders(join(here, "index.ts"), SERVER_ONLY)).toEqual([]);
+ });
+});
+
+describe("core stays provider-neutral", () => {
+ it("never imports the Stripe SDK - adapters live in their own package", () => {
+ const importsStripe = sourcesUnder(SRC_ROOT).filter(path =>
+ /from\s+["']stripe["']|import\(\s*["']stripe["']\s*\)/.test(
+ readFileSync(path, "utf8"),
+ ),
+ );
+
+ expect(importsStripe).toEqual([]);
+ });
+});
diff --git a/packages/vitnode/src/payments/config.test.ts b/packages/vitnode/src/payments/config.test.ts
new file mode 100644
index 0000000000..8791d4cf38
--- /dev/null
+++ b/packages/vitnode/src/payments/config.test.ts
@@ -0,0 +1,302 @@
+// @vitest-environment node
+import { describe, expect, it } from "vitest";
+
+import { must } from "@/tests/payments";
+
+import type { PaymentsConfig } from "./config";
+import type { PaymentOffer } from "./offer";
+import type { PaymentProvider } from "./provider";
+
+import {
+ createPaymentsRegistry,
+ publicPaymentsSettings,
+ purchasablePrices,
+ resolvePaymentsConfig,
+} from "./config";
+import { definePaymentOffer } from "./offer";
+
+const SECRET = "sk_test_must_never_leave_the_server";
+
+const provider = (
+ overrides: Partial = {},
+): PaymentProvider & { secretKey: string } => ({
+ checkout: {
+ create: async () => await Promise.reject(new Error("unused")),
+ expire: async () => await Promise.reject(new Error("unused")),
+ retrieve: async () => await Promise.reject(new Error("unused")),
+ },
+ currencies: ["PLN", "USD", "EUR"],
+ customers: {
+ create: async () => await Promise.resolve({ customerId: "cus_1" }),
+ },
+ id: "stripe",
+ name: "Stripe",
+ payments: { retrieve: async () => await Promise.reject(new Error("unused")) },
+ scope: "test",
+ // A field an adapter might keep around - it must never be projected.
+ secretKey: SECRET,
+ webhooks: { verify: async () => await Promise.reject(new Error("unused")) },
+ ...overrides,
+});
+
+const config = (overrides: Partial = {}): PaymentsConfig => ({
+ currencies: { PLN: { currencyDisplay: "code" }, USD: {} },
+ defaultCurrency: "PLN",
+ providers: [provider()],
+ ...overrides,
+});
+
+const lifetime = definePaymentOffer({
+ id: "lifetime",
+ mode: "one_time",
+ name: "Lifetime",
+ onPaid: async () => {},
+ prices: { EUR: 450, PLN: 1900, USD: 500 },
+ returnPath: "/example/payments",
+});
+
+describe("resolvePaymentsConfig", () => {
+ it("is disabled when payments is not configured", () => {
+ expect(resolvePaymentsConfig(undefined)).toBeNull();
+ });
+
+ it("fills operational defaults", () => {
+ const resolved = resolvePaymentsConfig(config());
+
+ expect(resolved?.defaultProvider.id).toBe("stripe");
+ expect(resolved?.checkoutExpiresInMinutes).toBe(60);
+ expect(resolved?.currencies).toEqual([
+ { code: "PLN", currencyDisplay: "code" },
+ { code: "USD", currencyDisplay: "symbol" },
+ ]);
+ });
+
+ it("refuses a default currency that is not enabled", () => {
+ expect(() =>
+ resolvePaymentsConfig(config({ defaultCurrency: "EUR" })),
+ ).toThrow(/defaultCurrency "EUR" is not one of the enabled currencies/);
+ });
+
+ it("refuses an unknown currency code", () => {
+ expect(() =>
+ resolvePaymentsConfig(
+ config({ currencies: { PLN: {}, usd: {} }, defaultCurrency: "PLN" }),
+ ),
+ ).toThrow(/ISO 4217/);
+ });
+
+ it("refuses duplicate provider ids", () => {
+ expect(() =>
+ resolvePaymentsConfig(
+ config({
+ defaultProvider: "stripe",
+ providers: [provider(), provider()],
+ }),
+ ),
+ ).toThrow(/registered twice/);
+ });
+
+ it("refuses a default provider that is not registered", () => {
+ expect(() =>
+ resolvePaymentsConfig(config({ defaultProvider: "paypal" })),
+ ).toThrow(/"paypal" is not registered/);
+ });
+
+ it("requires a default provider once there is a choice", () => {
+ expect(() =>
+ resolvePaymentsConfig(
+ config({ providers: [provider(), provider({ id: "stripe-eu" })] }),
+ ),
+ ).toThrow(/defaultProvider" is required/);
+ });
+
+ it("refuses an out-of-range checkout lifetime", () => {
+ expect(() =>
+ resolvePaymentsConfig(config({ checkoutExpiresInMinutes: 5 })),
+ ).toThrow(/between 45 and 1440/);
+ });
+
+ it("turns Payments off when no provider is registered", () => {
+ expect(resolvePaymentsConfig(config({ providers: [] }))).toBeNull();
+ });
+});
+
+describe("publicPaymentsSettings", () => {
+ it("projects only what the browser needs", () => {
+ const settings = publicPaymentsSettings(resolvePaymentsConfig(config()));
+
+ expect(settings).toEqual({
+ currencies: [
+ { code: "PLN", currencyDisplay: "code" },
+ { code: "USD", currencyDisplay: "symbol" },
+ ],
+ defaultCurrency: "PLN",
+ defaultProvider: "stripe",
+ enabled: true,
+ providers: [
+ {
+ capabilities: {
+ currencies: ["PLN", "USD", "EUR"],
+ customerPortal: false,
+ intervals: [],
+ oneTimePayments: true,
+ subscriptions: false,
+ },
+ id: "stripe",
+ name: "Stripe",
+ },
+ ],
+ });
+ expect(JSON.stringify(settings)).not.toContain(SECRET);
+ });
+
+ it("says disabled, with nothing else, when payments is off", () => {
+ expect(publicPaymentsSettings(null)).toEqual({
+ currencies: [],
+ defaultCurrency: null,
+ defaultProvider: null,
+ enabled: false,
+ providers: [],
+ });
+ });
+});
+
+describe("purchasablePrices", () => {
+ it("offers only enabled currencies the provider can charge", () => {
+ const resolved = resolvePaymentsConfig(
+ config({
+ currencies: { PLN: {}, USD: {} },
+ providers: [provider({ currencies: ["PLN"] })],
+ }),
+ );
+
+ // EUR is priced but not enabled; USD is enabled but the provider refuses it.
+ expect(
+ purchasablePrices(
+ must(resolved),
+ must(resolved).defaultProvider,
+ lifetime,
+ ),
+ ).toEqual([{ amount: 1900, currency: "PLN", interval: null }]);
+ });
+
+ it("applies the provider's own amount limits", () => {
+ const resolved = resolvePaymentsConfig(
+ config({
+ providers: [
+ provider({
+ checkAmount: money =>
+ money.amount < 1000 ? "Below the provider minimum." : null,
+ }),
+ ],
+ }),
+ );
+
+ expect(
+ purchasablePrices(
+ must(resolved),
+ must(resolved).defaultProvider,
+ lifetime,
+ ).map(price => price.currency),
+ ).toEqual(["PLN"]);
+ });
+
+ it("offers no subscription prices through a provider that cannot bill them", () => {
+ const resolved = resolvePaymentsConfig(config());
+ const plan = definePaymentOffer({
+ id: "plan",
+ intervals: { month: { PLN: 1900 } },
+ mode: "subscription",
+ name: "Plan",
+ onSubscriptionChanged: async () => {},
+ returnPath: "/example/payments",
+ });
+
+ expect(
+ purchasablePrices(must(resolved), must(resolved).defaultProvider, plan),
+ ).toEqual([]);
+ });
+});
+
+describe("createPaymentsRegistry", () => {
+ it("refuses two offers with one identity across modules", () => {
+ expect(() =>
+ createPaymentsRegistry({
+ config: undefined,
+ offers: [
+ { offer: lifetime, pluginId: "@acme/shop" },
+ { offer: lifetime, pluginId: "@acme/shop" },
+ ],
+ }),
+ ).toThrow(/"@acme\/shop:lifetime" is registered twice/);
+ });
+
+ it("allows the same offer id in two plugins", () => {
+ const registry = createPaymentsRegistry({
+ config: undefined,
+ offers: [
+ { offer: lifetime, pluginId: "@acme/a" },
+ { offer: lifetime, pluginId: "@acme/b" },
+ ],
+ });
+
+ expect([...registry.offers.keys()]).toEqual([
+ "@acme/a:lifetime",
+ "@acme/b:lifetime",
+ ]);
+ expect(registry.config).toBeNull();
+ });
+
+ it.each<[string, Partial | Record, RegExp]>([
+ ["an invalid mode", { mode: "lifetime" }, /mode must be/],
+ ["missing prices", { prices: {} }, /declares no prices/],
+ ["a zero price", { prices: { PLN: 0 } }, /greater than zero/],
+ ["a decimal price", { prices: { PLN: 19.99 } }, /whole number/],
+ ["an unknown currency", { prices: { ZZZ: 100 } }, /ISO 4217/],
+ ["no fulfillment handler", { onPaid: undefined }, /"onPaid" handler/],
+ [
+ "an external return URL",
+ { returnPath: "https://evil.example" },
+ /returnPath/,
+ ],
+ [
+ "a protocol-relative return URL",
+ { returnPath: "//evil.example" },
+ /returnPath/,
+ ],
+ ["an invalid id", { id: "Lifetime Offer" }, /lowercase/],
+ ])("refuses an offer with %s", (_, override, error) => {
+ expect(() =>
+ createPaymentsRegistry({
+ config: undefined,
+ offers: [
+ {
+ offer: { ...lifetime, ...override } as PaymentOffer,
+ pluginId: "@acme/shop",
+ },
+ ],
+ }),
+ ).toThrow(error);
+ });
+
+ it("refuses an unsupported recurring interval", () => {
+ expect(() =>
+ createPaymentsRegistry({
+ config: undefined,
+ offers: [
+ {
+ offer: {
+ id: "plan",
+ intervals: { week: { PLN: 100 } },
+ mode: "subscription",
+ name: "Plan",
+ onSubscriptionChanged: async () => {},
+ returnPath: "/plan",
+ } as unknown as PaymentOffer,
+ pluginId: "@acme/shop",
+ },
+ ],
+ }),
+ ).toThrow(/interval "week" is not supported/);
+ });
+});
diff --git a/packages/vitnode/src/payments/config.ts b/packages/vitnode/src/payments/config.ts
new file mode 100644
index 0000000000..e0d556b7f4
--- /dev/null
+++ b/packages/vitnode/src/payments/config.ts
@@ -0,0 +1,308 @@
+import type { CurrencyDisplay, Money } from "./money";
+import type { PaymentOffer, RegisteredPaymentOffer } from "./offer";
+import type { PaymentProvider, PaymentProviderCapabilities } from "./provider";
+import type { BillingInterval } from "./status";
+
+import { assertCurrencyCode, CURRENCY_DISPLAYS } from "./money";
+import {
+ offerPricesFor,
+ paymentOfferKey,
+ validatePaymentOffers,
+} from "./offer";
+import { PaymentsConfigError, providerCapabilities } from "./provider";
+
+export interface PaymentsCurrencyConfig {
+ /** `"code"` shows `19,00 PLN`, `"symbol"` shows `$19.00`. Default `"symbol"`. */
+ currencyDisplay?: CurrencyDisplay;
+}
+
+/**
+ * `payments` in `buildApiConfig`. Leave it out, or register no provider, and
+ * Payments is off.
+ */
+export interface PaymentsConfig {
+ /** How long a hosted checkout stays payable, 45-1440 minutes. Default 60. */
+ checkoutExpiresInMinutes?: number;
+ /** Every currency new purchases may use. Removing one keeps its history. */
+ currencies: Record;
+ /** Preselected in the UI. Must be one of `currencies`. */
+ defaultCurrency: string;
+ /** Required when more than one provider is registered. */
+ defaultProvider?: string;
+ /** Every provider buyers can pay through. Empty turns Payments off. */
+ providers: PaymentProvider[];
+ /**
+ * Days a processed webhook stays in the inbox before its row is deleted.
+ * Failed ones are kept until resolved. Default 30.
+ */
+ webhookRetentionDays?: number;
+}
+
+export interface ResolvedPaymentsCurrency {
+ code: string;
+ currencyDisplay: CurrencyDisplay;
+}
+
+export interface ResolvedPaymentsConfig {
+ checkoutExpiresInMinutes: number;
+ currencies: ResolvedPaymentsCurrency[];
+ defaultCurrency: string;
+ defaultProvider: PaymentProvider;
+ providers: PaymentProvider[];
+ webhookRetentionDays: number;
+}
+
+const DEFAULT_CHECKOUT_EXPIRES_IN_MINUTES = 60;
+const DEFAULT_WEBHOOK_RETENTION_DAYS = 30;
+const PROVIDER_ID = /^[a-z0-9][a-z0-9-]{0,31}$/;
+
+const wholeInRange = (
+ value: number | undefined,
+ fallback: number,
+ [min, max]: [number, number],
+ name: string,
+): number => {
+ if (value === undefined) return fallback;
+
+ if (!Number.isSafeInteger(value) || value < min || value > max) {
+ throw new PaymentsConfigError(
+ `"${name}" must be a whole number between ${min} and ${max}.`,
+ );
+ }
+
+ return value;
+};
+
+/**
+ * Validates `payments` once, at startup. Returns `null` when Payments is not
+ * configured or has no provider - every payment route then answers that
+ * Payments is disabled and every payments screen is hidden.
+ */
+export const resolvePaymentsConfig = (
+ config: PaymentsConfig | undefined,
+): null | ResolvedPaymentsConfig => {
+ if (!config) return null;
+
+ const currencyEntries = Object.entries(config.currencies ?? {});
+
+ if (currencyEntries.length === 0) {
+ throw new PaymentsConfigError(
+ 'Enable at least one currency in "payments.currencies", for example { USD: {} }.',
+ );
+ }
+
+ const currencies = currencyEntries.map(([code, options]) => {
+ try {
+ assertCurrencyCode(code);
+ } catch (error) {
+ throw new PaymentsConfigError(
+ `payments.currencies: ${error instanceof Error ? error.message : String(error)}`,
+ );
+ }
+
+ const currencyDisplay = options?.currencyDisplay ?? "symbol";
+
+ if (!CURRENCY_DISPLAYS.includes(currencyDisplay)) {
+ throw new PaymentsConfigError(
+ `payments.currencies.${code}.currencyDisplay must be one of ${CURRENCY_DISPLAYS.join(", ")}.`,
+ );
+ }
+
+ return { code, currencyDisplay };
+ });
+
+ if (!currencies.some(currency => currency.code === config.defaultCurrency)) {
+ throw new PaymentsConfigError(
+ `payments.defaultCurrency "${config.defaultCurrency}" is not one of the enabled currencies (${currencies.map(currency => currency.code).join(", ")}).`,
+ );
+ }
+
+ const providers = config.providers ?? [];
+
+ // No provider (say, no keys in this environment) means Payments is off.
+ if (providers.length === 0) return null;
+
+ const ids = new Set();
+
+ for (const provider of providers) {
+ if (typeof provider?.id !== "string" || !PROVIDER_ID.test(provider.id)) {
+ throw new PaymentsConfigError(
+ `Provider id "${provider?.id ?? "(missing)"}" must be 1-32 lowercase letters, digits or dashes.`,
+ );
+ }
+
+ if (ids.has(provider.id)) {
+ throw new PaymentsConfigError(
+ `Provider "${provider.id}" is registered twice in "payments.providers". Give the second one a different id.`,
+ );
+ }
+
+ ids.add(provider.id);
+ }
+
+ if (providers.length > 1 && !config.defaultProvider) {
+ throw new PaymentsConfigError(
+ '"payments.defaultProvider" is required when more than one provider is registered.',
+ );
+ }
+
+ const defaultProviderId = config.defaultProvider ?? providers[0].id;
+ const defaultProvider = providers.find(
+ provider => provider.id === defaultProviderId,
+ );
+
+ if (!defaultProvider) {
+ throw new PaymentsConfigError(
+ `payments.defaultProvider "${defaultProviderId}" is not registered. Registered: ${[...ids].join(", ")}.`,
+ );
+ }
+
+ return {
+ checkoutExpiresInMinutes: wholeInRange(
+ config.checkoutExpiresInMinutes,
+ DEFAULT_CHECKOUT_EXPIRES_IN_MINUTES,
+ // Stripe refuses an expiry under 30 minutes away; the margin covers the
+ // time a create can be retried with the attempt's original expiry.
+ [45, 1440],
+ "payments.checkoutExpiresInMinutes",
+ ),
+ currencies,
+ defaultCurrency: config.defaultCurrency,
+ defaultProvider,
+ providers,
+ webhookRetentionDays: wholeInRange(
+ config.webhookRetentionDays,
+ DEFAULT_WEBHOOK_RETENTION_DAYS,
+ [1, 3650],
+ "payments.webhookRetentionDays",
+ ),
+ };
+};
+
+/** The registry every request reads: config (or `null`) plus all offers. */
+export interface PaymentsRegistry {
+ config: null | ResolvedPaymentsConfig;
+ offers: Map;
+}
+
+export const createPaymentsRegistry = ({
+ config,
+ offers,
+}: {
+ config: PaymentsConfig | undefined;
+ offers: readonly RegisteredPaymentOffer[];
+}): PaymentsRegistry => {
+ const validated = validatePaymentOffers(offers);
+ const resolved = resolvePaymentsConfig(config);
+
+ return {
+ config: resolved,
+ offers: new Map(
+ validated.map(entry => [
+ paymentOfferKey(entry.pluginId, entry.offer.id),
+ entry,
+ ]),
+ ),
+ };
+};
+
+export const findPaymentProvider = (
+ config: ResolvedPaymentsConfig,
+ providerId: string | undefined,
+): PaymentProvider | undefined =>
+ providerId === undefined
+ ? config.defaultProvider
+ : config.providers.find(provider => provider.id === providerId);
+
+/** Why `money` cannot be bought through `provider` now, or `null`. */
+export const priceUnavailableReason = (
+ config: ResolvedPaymentsConfig,
+ provider: PaymentProvider,
+ offer: PaymentOffer,
+ money: Money,
+): null | string => {
+ if (offer.mode === "subscription" && !provider.subscriptions) {
+ return `Provider "${provider.id}" cannot bill subscriptions.`;
+ }
+
+ if (!config.currencies.some(currency => currency.code === money.currency)) {
+ return `${money.currency} is not enabled for new purchases.`;
+ }
+
+ if (!provider.currencies.includes(money.currency)) {
+ return `Provider "${provider.id}" does not support ${money.currency}.`;
+ }
+
+ return provider.checkAmount?.(money) ?? null;
+};
+
+export interface PurchasablePrice extends Money {
+ interval: BillingInterval | null;
+}
+
+/**
+ * The prices a buyer may actually choose: the offer's explicit prices,
+ * intersected with the enabled currencies and what the provider can charge.
+ */
+export const purchasablePrices = (
+ config: ResolvedPaymentsConfig,
+ provider: PaymentProvider,
+ offer: PaymentOffer,
+): PurchasablePrice[] => {
+ const intervals: (BillingInterval | null)[] =
+ offer.mode === "one_time"
+ ? [null]
+ : (Object.keys(offer.intervals) as BillingInterval[]);
+
+ return intervals.flatMap(interval =>
+ Object.entries(offerPricesFor(offer, interval) ?? {})
+ .map(([currency, amount]) => ({ amount, currency, interval }))
+ .filter(
+ price =>
+ priceUnavailableReason(config, provider, offer, price) === null,
+ ),
+ );
+};
+
+export interface PublicPaymentsSettings {
+ currencies: ResolvedPaymentsCurrency[];
+ defaultCurrency: null | string;
+ defaultProvider: null | string;
+ enabled: boolean;
+ providers: {
+ capabilities: PaymentProviderCapabilities;
+ id: string;
+ name: string;
+ }[];
+}
+
+/**
+ * Everything the browser may know about the configuration, and nothing more:
+ * no keys, no secrets, no provider objects. The route validates it against its
+ * schema on the way out.
+ */
+export const publicPaymentsSettings = (
+ config: null | ResolvedPaymentsConfig,
+): PublicPaymentsSettings =>
+ config
+ ? {
+ currencies: config.currencies.map(({ code, currencyDisplay }) => ({
+ code,
+ currencyDisplay,
+ })),
+ defaultCurrency: config.defaultCurrency,
+ defaultProvider: config.defaultProvider.id,
+ enabled: true,
+ providers: config.providers.map(provider => ({
+ capabilities: providerCapabilities(provider),
+ id: provider.id,
+ name: provider.name,
+ })),
+ }
+ : {
+ currencies: [],
+ defaultCurrency: null,
+ defaultProvider: null,
+ enabled: false,
+ providers: [],
+ };
diff --git a/packages/vitnode/src/payments/index.ts b/packages/vitnode/src/payments/index.ts
new file mode 100644
index 0000000000..b2ce15d3cd
--- /dev/null
+++ b/packages/vitnode/src/payments/index.ts
@@ -0,0 +1,5 @@
+export * from "./config";
+export * from "./money";
+export * from "./offer";
+export * from "./provider";
+export * from "./status";
diff --git a/packages/vitnode/src/payments/money.test.ts b/packages/vitnode/src/payments/money.test.ts
new file mode 100644
index 0000000000..08abaf1472
--- /dev/null
+++ b/packages/vitnode/src/payments/money.test.ts
@@ -0,0 +1,132 @@
+// @vitest-environment node
+import { describe, expect, it } from "vitest";
+
+import {
+ assertCurrencyCode,
+ assertMinorUnits,
+ currencyExponent,
+ formatMoney,
+ isCurrencyCode,
+ toDecimalString,
+} from "./money";
+
+// Intl separates an amount from a code with a no-break space in these locales.
+const nbsp = (value: string) => value.replace(/ /g, "Â ");
+
+describe("formatMoney", () => {
+ it("formats PLN for pl-PL with the code", () => {
+ expect(
+ formatMoney(
+ { amount: 1900, currency: "PLN" },
+ { currencyDisplay: "code", locale: "pl-PL" },
+ ),
+ ).toBe(nbsp("19,00 PLN"));
+ });
+
+ it("formats USD for en-US with the symbol", () => {
+ expect(
+ formatMoney(
+ { amount: 1900, currency: "USD" },
+ { currencyDisplay: "symbol", locale: "en-US" },
+ ),
+ ).toBe("$19.00");
+ });
+
+ it("formats a zero-decimal currency without inventing cents", () => {
+ expect(
+ formatMoney({ amount: 1900, currency: "JPY" }, { locale: "en-US" }),
+ ).toBe("Â¥1,900");
+ });
+
+ it("lets the locale place the symbol and pick separators", () => {
+ expect(
+ formatMoney(
+ { amount: 123456, currency: "EUR" },
+ { currencyDisplay: "symbol", locale: "de-DE" },
+ ),
+ ).toBe(nbsp("1.234,56 €"));
+ });
+
+ it("keeps the billing currency when only the UI locale changes", () => {
+ const money = { amount: 1900, currency: "PLN" };
+
+ expect(
+ formatMoney(money, { currencyDisplay: "code", locale: "en-US" }),
+ ).toBe(nbsp("PLN 19.00"));
+ });
+
+ it("formats three-decimal currencies with three places", () => {
+ expect(
+ formatMoney(
+ { amount: 1500, currency: "KWD" },
+ { currencyDisplay: "code", locale: "en-US" },
+ ),
+ ).toBe(nbsp("KWD 1.500"));
+ });
+
+ it("formats amounts too large for a float without rounding them", () => {
+ expect(
+ formatMoney(
+ { amount: 9007199254740991, currency: "USD" },
+ { locale: "en-US" },
+ ),
+ ).toBe("$90,071,992,547,409.91");
+ });
+});
+
+describe("toDecimalString", () => {
+ it.each([
+ [1900, "PLN", "19.00"],
+ [5, "USD", "0.05"],
+ [0, "USD", "0.00"],
+ [1900, "JPY", "1900"],
+ [1, "KWD", "0.001"],
+ ])("%i %s is %s", (amount, currency, expected) => {
+ expect(toDecimalString({ amount, currency })).toBe(expected);
+ });
+});
+
+describe("currencies", () => {
+ it("knows each currency's minor unit", () => {
+ expect(currencyExponent("PLN")).toBe(2);
+ expect(currencyExponent("JPY")).toBe(0);
+ expect(currencyExponent("BHD")).toBe(3);
+ });
+
+ it.each(["usd", "US", "ABC", "XAU", "XTS", 840, null])(
+ "refuses %s as a currency code",
+ code => {
+ expect(isCurrencyCode(code)).toBe(false);
+ expect(() => assertCurrencyCode(code)).toThrow(/ISO 4217/);
+ },
+ );
+});
+
+describe("assertMinorUnits", () => {
+ it("accepts whole amounts", () => {
+ expect(assertMinorUnits(1900)).toBe(1900);
+ expect(assertMinorUnits(0)).toBe(0);
+ });
+
+ it("refuses decimals - money is never a float", () => {
+ expect(() => assertMinorUnits(19.99)).toThrow(/whole number/);
+ });
+
+ it("refuses negative amounts", () => {
+ expect(() => assertMinorUnits(-1)).toThrow(/zero or more/);
+ });
+
+ it("refuses zero where a price is required", () => {
+ expect(() => assertMinorUnits(0, { positive: true })).toThrow(
+ /greater than zero/,
+ );
+ });
+
+ it("refuses amounts JSON cannot carry exactly", () => {
+ expect(() => assertMinorUnits(Number.MAX_SAFE_INTEGER + 1)).toThrow(
+ /whole number/,
+ );
+ expect(() => assertMinorUnits(Number.NaN)).toThrow();
+ expect(() => assertMinorUnits("1900")).toThrow();
+ });
+});
diff --git a/packages/vitnode/src/payments/money.ts b/packages/vitnode/src/payments/money.ts
new file mode 100644
index 0000000000..e81da609e6
--- /dev/null
+++ b/packages/vitnode/src/payments/money.ts
@@ -0,0 +1,210 @@
+/**
+ * Money in VitNode is an integer count of a currency's minor units plus a
+ * validated ISO 4217 code. Nothing in the payments pipeline does arithmetic on
+ * decimals: an amount is converted to a decimal string only to be displayed.
+ */
+export interface Money {
+ /** Whole minor units - `1900` is 19.00 PLN, `1900` is ¥1,900. */
+ amount: number;
+ currency: string;
+}
+
+/** How a currency is named next to an amount - `Intl.NumberFormat`'s option. */
+export const CURRENCY_DISPLAYS = [
+ "code",
+ "symbol",
+ "narrowSymbol",
+ "name",
+] as const;
+export type CurrencyDisplay = (typeof CURRENCY_DISPLAYS)[number];
+
+/**
+ * The largest amount VitNode stores or serialises. Amounts travel as JSON
+ * numbers, so anything above this would silently lose precision on the way.
+ */
+export const MAX_MONEY_AMOUNT = Number.MAX_SAFE_INTEGER;
+
+/**
+ * ISO 4217 minor-unit exponents that are not 2. Every other active code uses
+ * two decimal places. This is the currency's own definition and the only one
+ * VitNode displays with - a payment provider that counts a currency differently
+ * converts in its adapter.
+ */
+const ISO_EXPONENTS: Readonly> = {
+ BIF: 0,
+ CLP: 0,
+ DJF: 0,
+ GNF: 0,
+ ISK: 0,
+ JPY: 0,
+ KMF: 0,
+ KRW: 0,
+ PYG: 0,
+ RWF: 0,
+ UGX: 0,
+ UYI: 0,
+ VND: 0,
+ VUV: 0,
+ XAF: 0,
+ XOF: 0,
+ XPF: 0,
+ BHD: 3,
+ IQD: 3,
+ JOD: 3,
+ KWD: 3,
+ LYD: 3,
+ OMR: 3,
+ TND: 3,
+ CLF: 4,
+ UYW: 4,
+};
+
+/** Codes that exist in ISO 4217 but are not money anyone can be charged in. */
+const NON_TENDER = new Set([
+ "XAG",
+ "XAU",
+ "XBA",
+ "XBB",
+ "XBC",
+ "XBD",
+ "XDR",
+ "XPD",
+ "XPT",
+ "XSU",
+ "XTS",
+ "XUA",
+ "XXX",
+]);
+
+let knownCurrencies: ReadonlySet | undefined;
+
+const supportedByRuntime = (): ReadonlySet => {
+ knownCurrencies ??= new Set(Intl.supportedValuesOf("currency"));
+
+ return knownCurrencies;
+};
+
+export class MoneyError extends Error {
+ constructor(message: string) {
+ super(message);
+ this.name = "MoneyError";
+ }
+}
+
+/** Whether `code` is an uppercase ISO 4217 code for a real, chargeable currency. */
+export const isCurrencyCode = (code: unknown): code is string =>
+ typeof code === "string" &&
+ /^[A-Z]{3}$/.test(code) &&
+ !NON_TENDER.has(code) &&
+ supportedByRuntime().has(code);
+
+export const assertCurrencyCode = (code: unknown): string => {
+ if (!isCurrencyCode(code)) {
+ throw new MoneyError(
+ `"${String(code)}" is not a supported ISO 4217 currency code. Use three uppercase letters, for example "USD" or "PLN".`,
+ );
+ }
+
+ return code;
+};
+
+/** How many decimal places the currency's minor unit has (`2` for PLN, `0` for JPY). */
+export const currencyExponent = (currency: string): number =>
+ ISO_EXPONENTS[assertCurrencyCode(currency)] ?? 2;
+
+/**
+ * Refuses anything that is not a whole, safe, in-range number of minor units.
+ * `positive` is for prices: a purchase of nothing is not routed through a
+ * payment provider.
+ */
+export const assertMinorUnits = (
+ amount: unknown,
+ { positive = false }: { positive?: boolean } = {},
+): number => {
+ if (typeof amount !== "number" || !Number.isSafeInteger(amount)) {
+ throw new MoneyError(
+ `Amount ${String(amount)} must be a whole number of minor units (for example 1900 for 19.00), not a decimal.`,
+ );
+ }
+
+ if (amount < 0 || (positive && amount === 0)) {
+ throw new MoneyError(
+ `Amount ${amount} must be ${positive ? "greater than zero" : "zero or more"}.`,
+ );
+ }
+
+ if (amount > MAX_MONEY_AMOUNT) {
+ throw new MoneyError(`Amount ${amount} is too large to store safely.`);
+ }
+
+ return amount;
+};
+
+export const assertMoney = (
+ money: { amount: unknown; currency: unknown },
+ options?: { positive?: boolean },
+): Money => ({
+ amount: assertMinorUnits(money.amount, options),
+ currency: assertCurrencyCode(money.currency),
+});
+
+/**
+ * The exact decimal string of an amount - `"19.00"` for 1900 PLN, `"1900"` for
+ * 1900 JPY. Built from the integer digits, so no floating point is involved.
+ */
+export const toDecimalString = ({ amount, currency }: Money): string => {
+ const exponent = currencyExponent(currency);
+ const negative = amount < 0;
+ const digits = String(Math.abs(amount)).padStart(exponent + 1, "0");
+ const whole = digits.slice(0, digits.length - exponent);
+ const fraction = digits.slice(digits.length - exponent);
+
+ return `${negative ? "-" : ""}${whole}${exponent > 0 ? `.${fraction}` : ""}`;
+};
+
+const formatters = new Map();
+
+const formatterFor = (
+ locale: string,
+ currency: string,
+ currencyDisplay: CurrencyDisplay,
+): Intl.NumberFormat => {
+ const key = `${locale}|${currency}|${currencyDisplay}`;
+ let formatter = formatters.get(key);
+
+ if (!formatter) {
+ const exponent = currencyExponent(currency);
+ formatter = new Intl.NumberFormat(locale, {
+ currency,
+ currencyDisplay,
+ maximumFractionDigits: exponent,
+ minimumFractionDigits: exponent,
+ style: "currency",
+ });
+ formatters.set(key, formatter);
+ }
+
+ return formatter;
+};
+
+/**
+ * Formats an amount for people: the locale decides separators, spacing and
+ * where the currency goes; `currencyDisplay` decides whether it is `PLN` or
+ * `zł`. The UI locale and the billing currency are independent - switching
+ * language never changes which currency is shown.
+ *
+ * ```ts
+ * formatMoney({ amount: 1900, currency: "PLN" }, { locale: "pl-PL", currencyDisplay: "code" });
+ * // "19,00 PLN"
+ * ```
+ */
+export const formatMoney = (
+ money: Money,
+ {
+ currencyDisplay = "symbol",
+ locale,
+ }: { currencyDisplay?: CurrencyDisplay; locale: string },
+): string =>
+ formatterFor(locale, money.currency, currencyDisplay).format(
+ toDecimalString(money) as `${number}`,
+ );
diff --git a/packages/vitnode/src/payments/offer.ts b/packages/vitnode/src/payments/offer.ts
new file mode 100644
index 0000000000..159e816cac
--- /dev/null
+++ b/packages/vitnode/src/payments/offer.ts
@@ -0,0 +1,288 @@
+import type { Context } from "hono";
+
+import type { EnvVitNode } from "@/api/middlewares/global.middleware";
+
+import type {
+ BillingInterval,
+ FulfillmentStatus,
+ RefundStatus,
+ SubscriptionStatus,
+} from "./status";
+
+import { assertCurrencyCode, assertMinorUnits } from "./money";
+import { PaymentsConfigError } from "./provider";
+import { BILLING_INTERVALS } from "./status";
+
+/** The transaction a payment handler writes through. Roll it back by throwing. */
+export type PaymentsTransaction = Omit<
+ EnvVitNode["Variables"]["db"],
+ "$client"
+>;
+
+/** Explicit prices, one per currency code, in minor units. No conversion. */
+export type OfferPrices = Readonly>;
+
+export type PaymentOfferEligibility =
+ | { eligible: false; reason: string }
+ | { eligible: true };
+
+export interface PaymentOfferEligibilityArgs {
+ c: Context;
+ interval: BillingInterval | null;
+ /** Reads inside the checkout's lock, so two clicks cannot both pass. */
+ tx: PaymentsTransaction;
+ userId: number;
+}
+
+/** What a purchase looked like when it was bought. Never the live offer. */
+export interface PaymentPurchaseSnapshot {
+ amount: number;
+ currency: string;
+ fulfillmentStatus: FulfillmentStatus;
+ id: string;
+ interval: BillingInterval | null;
+ offerId: string;
+ offerName: string;
+ paidAt: Date | null;
+ pluginId: string;
+ refundedAmount: number;
+ refundStatus: RefundStatus;
+ userId: null | number;
+}
+
+export interface PaymentSubscriptionSnapshot {
+ amount: number;
+ cancelAtPeriodEnd: boolean;
+ currency: string;
+ endedAt: Date | null;
+ id: string;
+ interval: BillingInterval;
+ offerId: string;
+ /** End of the last period a verified payment covers. `null` before the first. */
+ paidThrough: Date | null;
+ pluginId: string;
+ purchaseId: string;
+ status: SubscriptionStatus;
+ userId: null | number;
+}
+
+export interface PaymentHandlerArgs {
+ c: Context;
+ /**
+ * Write the business effect through this. It commits together with the
+ * fulfillment record, so a retried job never grants twice; throw to roll back
+ * and let the queue try again.
+ */
+ tx: PaymentsTransaction;
+}
+
+export interface PaymentOfferBase {
+ /** Stable within the plugin - stored on every purchase. Never rename it. */
+ id: string;
+ /**
+ * Server-side gate run before every checkout, e.g. "already owns it". The
+ * browser can never skip it.
+ */
+ isEligible?: (
+ args: PaymentOfferEligibilityArgs,
+ ) => PaymentOfferEligibility | Promise;
+ /** Snapshot name stored on purchases and shown on the hosted checkout page. */
+ name: string;
+ /**
+ * A message key (full path, e.g. `@vitnode/example.payments.offers.lifetime.name`)
+ * used instead of `name` for the buyer's language when the API has it.
+ */
+ nameKey?: string;
+ /**
+ * Where the buyer lands after checkout - a path in this app such as
+ * `/example/payments`. Never taken from the browser.
+ */
+ returnPath: string;
+}
+
+export interface OneTimePaymentOffer extends PaymentOfferBase {
+ mode: "one_time";
+ /** Grant what was bought. Runs once per paid purchase, after verification. */
+ onPaid: (
+ args: PaymentHandlerArgs & { purchase: PaymentPurchaseSnapshot },
+ ) => Promise;
+ /**
+ * Called once per refund the provider confirms, partial or full. Core only
+ * reports it - what it means for access is the plugin's decision.
+ */
+ onRefunded?: (
+ args: PaymentHandlerArgs & {
+ purchase: PaymentPurchaseSnapshot;
+ refund: { amount: number; id: string };
+ },
+ ) => Promise;
+ prices: OfferPrices;
+}
+
+export interface SubscriptionPaymentOffer extends PaymentOfferBase {
+ /** Prices per billing interval. Only `month` and `year` are supported. */
+ intervals: Partial>;
+ mode: "subscription";
+ /**
+ * Called whenever the local subscription record changed - payment, renewal,
+ * failed renewal, scheduled cancellation, end. Set access from the snapshot
+ * (for example to `paidThrough`); never add to it, because the same state can
+ * arrive more than once.
+ */
+ onSubscriptionChanged: (
+ args: PaymentHandlerArgs & { subscription: PaymentSubscriptionSnapshot },
+ ) => Promise;
+}
+
+export type PaymentOffer = OneTimePaymentOffer | SubscriptionPaymentOffer;
+
+export interface RegisteredPaymentOffer {
+ offer: PaymentOffer;
+ pluginId: string;
+}
+
+const OFFER_ID = /^[a-z0-9][a-z0-9-]{0,62}$/;
+
+export const paymentOfferKey = (pluginId: string, offerId: string): string =>
+ `${pluginId}:${offerId}`;
+
+/** A same-origin, absolute path - no scheme, no host, no protocol-relative trick. */
+export const isSafeReturnPath = (path: string): boolean =>
+ path.startsWith("/") &&
+ !path.startsWith("//") &&
+ !path.includes("\\") &&
+ ![...path].some(char => char.trim() === "" || char.charCodeAt(0) < 32) &&
+ !/^\/[^/]*:/.test(path);
+
+const assertPrices = (prices: OfferPrices, where: string): void => {
+ const entries = Object.entries(prices);
+
+ if (entries.length === 0) {
+ throw new PaymentsConfigError(`${where} declares no prices.`);
+ }
+
+ for (const [currency, amount] of entries) {
+ try {
+ assertCurrencyCode(currency);
+ assertMinorUnits(amount, { positive: true });
+ } catch (error) {
+ throw new PaymentsConfigError(
+ `${where}, price ${currency}: ${error instanceof Error ? error.message : String(error)}`,
+ );
+ }
+ }
+};
+
+/** Structural checks on one offer. Currency availability is checked at boot. */
+export const validatePaymentOffer = (
+ offer: PaymentOffer,
+ pluginId: string,
+): void => {
+ const where = `Offer "${paymentOfferKey(pluginId, offer.id)}"`;
+
+ if (typeof offer.id !== "string" || !OFFER_ID.test(offer.id)) {
+ throw new PaymentsConfigError(
+ `${where}: the id must be 1-63 lowercase letters, digits or dashes.`,
+ );
+ }
+
+ if (typeof offer.name !== "string" || offer.name.trim() === "") {
+ throw new PaymentsConfigError(`${where}: "name" is required.`);
+ }
+
+ if (
+ typeof offer.returnPath !== "string" ||
+ !isSafeReturnPath(offer.returnPath)
+ ) {
+ throw new PaymentsConfigError(
+ `${where}: "returnPath" must be a path in this app, such as "/example/payments".`,
+ );
+ }
+
+ const mode: unknown = offer.mode;
+
+ if (mode === "one_time") {
+ const oneTime = offer as OneTimePaymentOffer;
+ assertPrices(oneTime.prices ?? {}, where);
+
+ if (typeof oneTime.onPaid !== "function") {
+ throw new PaymentsConfigError(
+ `${where}: a one-time offer needs an "onPaid" handler to fulfill it.`,
+ );
+ }
+
+ return;
+ }
+
+ if (mode === "subscription") {
+ const subscription = offer as SubscriptionPaymentOffer;
+ const intervals = Object.entries(subscription.intervals ?? {});
+
+ if (intervals.length === 0) {
+ throw new PaymentsConfigError(
+ `${where}: a subscription offer needs prices for "month" and/or "year".`,
+ );
+ }
+
+ for (const [interval, prices] of intervals) {
+ if (!(BILLING_INTERVALS as readonly string[]).includes(interval)) {
+ throw new PaymentsConfigError(
+ `${where}: interval "${interval}" is not supported. Use "month" or "year".`,
+ );
+ }
+
+ assertPrices(prices ?? {}, `${where} (${interval})`);
+ }
+
+ if (typeof subscription.onSubscriptionChanged !== "function") {
+ throw new PaymentsConfigError(
+ `${where}: a subscription offer needs an "onSubscriptionChanged" handler.`,
+ );
+ }
+
+ return;
+ }
+
+ throw new PaymentsConfigError(
+ `${where}: mode must be "one_time" or "subscription".`,
+ );
+};
+
+/** Validates every offer and refuses two with the same plugin and id. */
+export const validatePaymentOffers = (
+ entries: readonly RegisteredPaymentOffer[],
+): RegisteredPaymentOffer[] => {
+ const seen = new Set();
+
+ for (const { offer, pluginId } of entries) {
+ validatePaymentOffer(offer, pluginId);
+ const key = paymentOfferKey(pluginId, offer.id);
+
+ if (seen.has(key)) {
+ throw new PaymentsConfigError(
+ `Offer "${key}" is registered twice. Offer ids must be unique within a plugin.`,
+ );
+ }
+
+ seen.add(key);
+ }
+
+ return [...entries];
+};
+
+/** Declares an offer a plugin sells. Validated when the plugin is built. */
+export const definePaymentOffer = (offer: T): T =>
+ offer;
+
+/** The price list one offer charges for an interval (`null` for one-time). */
+export const offerPricesFor = (
+ offer: PaymentOffer,
+ interval: BillingInterval | null,
+): OfferPrices | undefined =>
+ offer.mode === "one_time"
+ ? interval === null
+ ? offer.prices
+ : undefined
+ : interval === null
+ ? undefined
+ : offer.intervals[interval];
diff --git a/packages/vitnode/src/payments/provider.ts b/packages/vitnode/src/payments/provider.ts
new file mode 100644
index 0000000000..e14b85aa81
--- /dev/null
+++ b/packages/vitnode/src/payments/provider.ts
@@ -0,0 +1,272 @@
+import type { Money } from "./money";
+import type {
+ BillingInterval,
+ DisputeStatus,
+ InvoiceStatus,
+ OfferMode,
+ SubscriptionStatus,
+} from "./status";
+
+/**
+ * What core asks a payment provider to do, in core's own vocabulary. An adapter
+ * (for example `@vitnode/stripe`) translates these into SDK calls and its
+ * objects back into the shapes below - no provider object ever reaches core or
+ * a plugin.
+ *
+ * Every amount crossing this boundary is in ISO minor units (see `Money`). A
+ * provider that counts a currency differently converts inside the adapter.
+ */
+export interface PaymentProvider {
+ /**
+ * Provider-specific limits on one charge. Returns why `money` cannot be
+ * charged, or `null` when it can.
+ */
+ checkAmount?: (money: Money) => null | string;
+ checkout: {
+ create: (
+ input: ProviderCheckoutInput,
+ options: { idempotencyKey: string },
+ ) => Promise;
+ /** Closes an open checkout so it can no longer be paid. */
+ expire: (checkoutId: string) => Promise;
+ retrieve: (checkoutId: string) => Promise;
+ };
+ /** Codes this provider can charge in. Intersected with the install's own. */
+ currencies: readonly string[];
+ customers: {
+ create: (
+ input: { email: string; name: string; userId: number },
+ options: { idempotencyKey: string },
+ ) => Promise<{ customerId: string }>;
+ };
+ /** Stable id used in config, URLs and stored references - `"stripe"`. */
+ id: string;
+ /** Shown in the AdminCP and the billing screens. */
+ name: string;
+ payments: {
+ /** Refund and dispute facts for one payment, read fresh. */
+ retrieve: (paymentId: string) => Promise;
+ };
+ /** Present only when the provider has a hosted billing portal. */
+ portal?: {
+ createSession: (input: {
+ customerId: string;
+ locale?: string;
+ returnUrl: string;
+ }) => Promise<{ url: string }>;
+ };
+ /**
+ * Which account or environment references belong to - for Stripe `"test"`
+ * or `"live"`. Stored next to every provider reference so ids from two
+ * environments can never collide.
+ */
+ scope: string;
+ /** Present only when the provider can bill on a schedule. */
+ subscriptions?: {
+ retrieve: (subscriptionId: string) => Promise;
+ retrieveInvoice: (invoiceId: string) => Promise;
+ };
+ webhooks: {
+ /**
+ * Verifies the request against the provider's signature using the exact
+ * bytes that arrived, then reduces it to a normalized event. Throws
+ * `PaymentWebhookSignatureError` for anything not provably from the
+ * provider.
+ */
+ verify: (input: {
+ body: Uint8Array;
+ headers: Headers;
+ }) => Promise;
+ };
+}
+
+export interface PaymentProviderCapabilities {
+ currencies: readonly string[];
+ customerPortal: boolean;
+ intervals: readonly BillingInterval[];
+ oneTimePayments: boolean;
+ subscriptions: boolean;
+}
+
+export const providerCapabilities = (
+ provider: PaymentProvider,
+): PaymentProviderCapabilities => ({
+ currencies: provider.currencies,
+ customerPortal: !!provider.portal,
+ intervals: provider.subscriptions ? ["month", "year"] : [],
+ oneTimePayments: true,
+ subscriptions: !!provider.subscriptions,
+});
+
+export interface ProviderCheckoutInput {
+ /** Cancel/back destination, built by core from the offer's return path. */
+ cancelUrl: string;
+ customerId: string;
+ expiresAt: Date;
+ /** Shown on the hosted page. */
+ item: Money & { interval?: BillingInterval; name: string };
+ locale?: string;
+ /** Small, non-sensitive strings copied onto provider objects. */
+ metadata: Record;
+ mode: OfferMode;
+ /** Core's opaque purchase id, echoed back on every related object. */
+ reference: string;
+ successUrl: string;
+}
+
+export interface ProviderCheckout {
+ /** In minor units, as charged - `null` until the provider knows it. */
+ amountTotal: null | number;
+ currency: null | string;
+ customerId: null | string;
+ expiresAt: Date | null;
+ id: string;
+ /** The payment the checkout created, for one-time purchases. */
+ paymentId: null | string;
+ /**
+ * `failed` when the checkout completed but its asynchronous payment was
+ * declined - a provider must tell that apart from still-processing.
+ */
+ paymentStatus: "failed" | "no_payment_required" | "paid" | "unpaid";
+ reference: null | string;
+ status: "complete" | "expired" | "open";
+ subscriptionId: null | string;
+ url: null | string;
+}
+
+export interface ProviderSubscription {
+ /** Recurring price in minor units, as the provider holds it. */
+ amount: null | number;
+ cancelAt: Date | null;
+ cancelAtPeriodEnd: boolean;
+ canceledAt: Date | null;
+ currency: null | string;
+ /** When the current billing period ends - not proof that it is paid. */
+ currentPeriodEnd: Date | null;
+ customerId: string;
+ endedAt: Date | null;
+ id: string;
+ interval: BillingInterval | null;
+ latestInvoiceId: null | string;
+ priceId: null | string;
+ /** The provider's own status string, kept for support and debugging. */
+ providerStatus: string;
+ reference: null | string;
+ status: SubscriptionStatus;
+}
+
+export interface ProviderInvoice {
+ amountDue: number;
+ amountPaid: number;
+ billingReason: null | string;
+ currency: string;
+ id: string;
+ paidAt: Date | null;
+ paymentId: null | string;
+ /** The service period this invoice bills for. */
+ periodEnd: Date | null;
+ periodStart: Date | null;
+ providerStatus: string;
+ /** `open` when unpaid; the webhook signal decides `payment_failed` / `action_required`. */
+ status: Extract;
+ subscriptionId: null | string;
+}
+
+export interface ProviderRefund {
+ amount: number;
+ id: string;
+ status: "canceled" | "failed" | "pending" | "requires_action" | "succeeded";
+}
+
+export interface ProviderDispute {
+ amount: number;
+ id: string;
+ providerStatus: string;
+ reason: null | string;
+ status: DisputeStatus;
+}
+
+export interface ProviderPayment {
+ amount: number;
+ /** Total of refunds that succeeded. */
+ amountRefunded: number;
+ currency: string;
+ disputes: ProviderDispute[];
+ id: string;
+ refunds: ProviderRefund[];
+}
+
+/** The object a webhook is about - core re-reads it before acting. */
+export type ProviderWebhookTarget =
+ | { id: string; kind: "checkout" }
+ | {
+ id: string;
+ kind: "invoice";
+ signal: "action_required" | "paid" | "payment_failed" | "updated";
+ }
+ | { id: string; kind: "payment" }
+ | { id: string; kind: "subscription" };
+
+export interface ProviderWebhookEvent {
+ /** The provider's event id - the deduplication key. */
+ id: string;
+ /** Account/environment the event belongs to; compared with `provider.scope`. */
+ scope: string;
+ /** `null` for event types core does not use - acknowledged, never stored. */
+ target: null | ProviderWebhookTarget;
+ /** The provider's own event type, for the AdminCP. */
+ type: string;
+}
+
+export type PaymentProviderErrorKind =
+ /** The provider refused the request; repeating it will not help. */
+ | "not_found"
+ /** The object does not exist (in this account/environment). */
+ | "rejected"
+ /** No answer: the request may or may not have taken effect. */
+ | "unavailable"
+ /** Throttled or down; safe to try again later. */
+ | "uncertain";
+
+export class PaymentProviderError extends Error {
+ constructor(
+ kind: PaymentProviderErrorKind,
+ message: string,
+ options?: { cause?: unknown },
+ ) {
+ super(message, options);
+ this.name = "PaymentProviderError";
+ this.kind = kind;
+ }
+
+ readonly kind: PaymentProviderErrorKind;
+
+ get retryable(): boolean {
+ return this.kind === "uncertain" || this.kind === "unavailable";
+ }
+}
+
+export const isPaymentProviderError = (
+ error: unknown,
+): error is PaymentProviderError =>
+ error instanceof Error && error.name === "PaymentProviderError";
+
+export class PaymentWebhookSignatureError extends Error {
+ constructor(message = "The webhook signature could not be verified.") {
+ super(message);
+ this.name = "PaymentWebhookSignatureError";
+ }
+}
+
+export const isPaymentWebhookSignatureError = (
+ error: unknown,
+): error is PaymentWebhookSignatureError =>
+ error instanceof Error && error.name === "PaymentWebhookSignatureError";
+
+/** Thrown by an adapter factory or core when explicit configuration is wrong. */
+export class PaymentsConfigError extends Error {
+ constructor(message: string) {
+ super(`[VitNode Payments] ${message}`);
+ this.name = "PaymentsConfigError";
+ }
+}
diff --git a/packages/vitnode/src/payments/status.test.ts b/packages/vitnode/src/payments/status.test.ts
new file mode 100644
index 0000000000..e8cbb0e478
--- /dev/null
+++ b/packages/vitnode/src/payments/status.test.ts
@@ -0,0 +1,111 @@
+// @vitest-environment node
+import { describe, expect, it } from "vitest";
+
+import {
+ isPurchaseSettled,
+ nextPurchasePaymentStatus,
+ purchaseDisplayState,
+ subscriptionDisplayState,
+} from "./status";
+
+describe("nextPurchasePaymentStatus", () => {
+ it("never moves a paid purchase backwards", () => {
+ expect(nextPurchasePaymentStatus("paid", "awaiting_payment")).toBe("paid");
+ expect(nextPurchasePaymentStatus("paid", "processing")).toBe("paid");
+ expect(nextPurchasePaymentStatus("paid", "expired")).toBe("paid");
+ });
+
+ it("ignores a stale snapshot older than what is already known", () => {
+ expect(nextPurchasePaymentStatus("processing", "awaiting_payment")).toBe(
+ "processing",
+ );
+ });
+
+ it("settles an asynchronous payment", () => {
+ expect(nextPurchasePaymentStatus("processing", "paid")).toBe("paid");
+ expect(nextPurchasePaymentStatus("processing", "failed")).toBe("failed");
+ });
+
+ it("keeps a terminal failure unless money actually arrived", () => {
+ expect(nextPurchasePaymentStatus("expired", "awaiting_payment")).toBe(
+ "expired",
+ );
+ expect(nextPurchasePaymentStatus("failed", "processing")).toBe("failed");
+ expect(nextPurchasePaymentStatus("expired", "paid")).toBe("paid");
+ });
+});
+
+describe("purchaseDisplayState", () => {
+ const paid = {
+ fulfillmentStatus: "fulfilled" as const,
+ paymentStatus: "paid" as const,
+ refundStatus: "none" as const,
+ };
+
+ it("shows a paid purchase whose fulfillment failed as paid, not failed", () => {
+ expect(purchaseDisplayState({ ...paid, fulfillmentStatus: "failed" })).toBe(
+ "fulfillment_failed",
+ );
+ });
+
+ it("distinguishes pending fulfillment", () => {
+ expect(
+ purchaseDisplayState({ ...paid, fulfillmentStatus: "pending" }),
+ ).toBe("fulfillment_pending");
+ });
+
+ it("reports refunds over fulfillment", () => {
+ expect(purchaseDisplayState({ ...paid, refundStatus: "partial" })).toBe(
+ "partially_refunded",
+ );
+ expect(purchaseDisplayState({ ...paid, refundStatus: "full" })).toBe(
+ "refunded",
+ );
+ });
+
+ it("groups expired and canceled attempts as failed", () => {
+ expect(
+ purchaseDisplayState({
+ ...paid,
+ fulfillmentStatus: "not_started",
+ paymentStatus: "expired",
+ }),
+ ).toBe("failed");
+ });
+});
+
+describe("subscriptionDisplayState", () => {
+ it.each([
+ [{ cancelAtPeriodEnd: false, status: "active" }, "active"],
+ [{ cancelAtPeriodEnd: true, status: "active" }, "cancellation_scheduled"],
+ [{ cancelAtPeriodEnd: false, status: "past_due" }, "needs_attention"],
+ [{ cancelAtPeriodEnd: false, status: "unpaid" }, "needs_attention"],
+ [{ cancelAtPeriodEnd: true, status: "canceled" }, "ended"],
+ [{ cancelAtPeriodEnd: false, status: "incomplete" }, "incomplete"],
+ ] as const)("%o is %s", (subscription, expected) => {
+ expect(subscriptionDisplayState(subscription)).toBe(expected);
+ });
+});
+
+describe("isPurchaseSettled", () => {
+ it("keeps polling while paid but not yet fulfilled", () => {
+ expect(
+ isPurchaseSettled({
+ fulfillmentStatus: "pending",
+ paymentStatus: "paid",
+ }),
+ ).toBe(false);
+ expect(
+ isPurchaseSettled({
+ fulfillmentStatus: "fulfilled",
+ paymentStatus: "paid",
+ }),
+ ).toBe(true);
+ expect(
+ isPurchaseSettled({
+ fulfillmentStatus: "not_started",
+ paymentStatus: "expired",
+ }),
+ ).toBe(true);
+ });
+});
diff --git a/packages/vitnode/src/payments/status.ts b/packages/vitnode/src/payments/status.ts
new file mode 100644
index 0000000000..f14fb0e49f
--- /dev/null
+++ b/packages/vitnode/src/payments/status.ts
@@ -0,0 +1,185 @@
+/**
+ * Payments keeps one status per concern instead of one status for everything.
+ * A purchase can be `paid` while its fulfillment `failed`, and refunding part of
+ * it never changes either of those.
+ */
+
+/** Whether the buyer's money arrived. Never moves backwards from `paid`. */
+export const PURCHASE_PAYMENT_STATUSES = [
+ "awaiting_payment",
+ "processing",
+ "paid",
+ "failed",
+ "expired",
+ "canceled",
+] as const;
+export type PurchasePaymentStatus = (typeof PURCHASE_PAYMENT_STATUSES)[number];
+
+/** Whether the plugin delivered what was paid for. Separate from payment. */
+export const FULFILLMENT_STATUSES = [
+ "not_started",
+ "pending",
+ "fulfilled",
+ "failed",
+] as const;
+export type FulfillmentStatus = (typeof FULFILLMENT_STATUSES)[number];
+
+export const REFUND_STATUSES = ["none", "partial", "full"] as const;
+export type RefundStatus = (typeof REFUND_STATUSES)[number];
+
+/** A dispute's state, reduced to what a reader of the record needs. */
+export const DISPUTE_STATUSES = ["open", "won", "lost"] as const;
+export type DisputeStatus = (typeof DISPUTE_STATUSES)[number];
+
+/** One hosted checkout attempt for a purchase. */
+export const CHECKOUT_STATUSES = [
+ /** Persisted, provider not asked yet. */
+ "creating",
+ /** The provider call ended without an answer - it may or may not exist. */
+ "unknown",
+ "open",
+ "complete",
+ "expired",
+ /** The provider refused to create it. */
+ "failed",
+] as const;
+export type CheckoutStatus = (typeof CHECKOUT_STATUSES)[number];
+
+export const SUBSCRIPTION_STATUSES = [
+ "incomplete",
+ "active",
+ "past_due",
+ "unpaid",
+ "paused",
+ "canceled",
+ "incomplete_expired",
+] as const;
+export type SubscriptionStatus = (typeof SUBSCRIPTION_STATUSES)[number];
+
+/** Statuses after which a subscription bills nobody again. */
+export const TERMINAL_SUBSCRIPTION_STATUSES: readonly SubscriptionStatus[] = [
+ "canceled",
+ "incomplete_expired",
+];
+
+export const INVOICE_STATUSES = [
+ "open",
+ "paid",
+ "payment_failed",
+ "action_required",
+ "void",
+ "uncollectible",
+] as const;
+export type InvoiceStatus = (typeof INVOICE_STATUSES)[number];
+
+export const BILLING_INTERVALS = ["month", "year"] as const;
+export type BillingInterval = (typeof BILLING_INTERVALS)[number];
+
+export const OFFER_MODES = ["one_time", "subscription"] as const;
+export type OfferMode = (typeof OFFER_MODES)[number];
+
+const PAYMENT_RANK: Record = {
+ awaiting_payment: 0,
+ processing: 1,
+ canceled: 2,
+ expired: 2,
+ failed: 2,
+ paid: 3,
+};
+
+/**
+ * The status a purchase moves to when `observed` is reported for it.
+ *
+ * Moves only forward: an older snapshot that still says `awaiting_payment`
+ * cannot undo `processing`, and nothing undoes `paid`. A terminal failure can
+ * still become `paid` - an asynchronous payment that settles after its checkout
+ * was marked expired locally is money that arrived, and history has to say so.
+ */
+export const nextPurchasePaymentStatus = (
+ current: PurchasePaymentStatus,
+ observed: PurchasePaymentStatus,
+): PurchasePaymentStatus => {
+ if (current === "paid") return "paid";
+ if (observed === "paid") return "paid";
+ if (PAYMENT_RANK[current] === 2) return current;
+
+ return PAYMENT_RANK[observed] > PAYMENT_RANK[current] ? observed : current;
+};
+
+/** What a purchase row looks like to someone reading it - one label, derived. */
+export type PurchaseDisplayState =
+ | "awaiting_payment"
+ | "failed"
+ | "fulfilled"
+ | "fulfillment_failed"
+ | "fulfillment_pending"
+ | "paid"
+ | "partially_refunded"
+ | "processing"
+ | "refunded";
+
+export const purchaseDisplayState = (purchase: {
+ fulfillmentStatus: FulfillmentStatus;
+ paymentStatus: PurchasePaymentStatus;
+ refundStatus: RefundStatus;
+}): PurchaseDisplayState => {
+ switch (purchase.paymentStatus) {
+ case "awaiting_payment":
+ return "awaiting_payment";
+ case "canceled":
+ case "expired":
+ case "failed":
+ return "failed";
+ case "paid":
+ break;
+ case "processing":
+ return "processing";
+ }
+
+ if (purchase.refundStatus === "full") return "refunded";
+ if (purchase.refundStatus === "partial") return "partially_refunded";
+ if (purchase.fulfillmentStatus === "failed") return "fulfillment_failed";
+ if (purchase.fulfillmentStatus === "pending") return "fulfillment_pending";
+ if (purchase.fulfillmentStatus === "fulfilled") return "fulfilled";
+
+ return "paid";
+};
+
+export type SubscriptionDisplayState =
+ | "active"
+ | "cancellation_scheduled"
+ | "ended"
+ | "incomplete"
+ | "needs_attention";
+
+export const subscriptionDisplayState = (subscription: {
+ cancelAtPeriodEnd: boolean;
+ status: SubscriptionStatus;
+}): SubscriptionDisplayState => {
+ if (TERMINAL_SUBSCRIPTION_STATUSES.includes(subscription.status)) {
+ return "ended";
+ }
+
+ if (subscription.status === "incomplete") return "incomplete";
+
+ if (
+ subscription.status === "past_due" ||
+ subscription.status === "unpaid" ||
+ subscription.status === "paused"
+ ) {
+ return "needs_attention";
+ }
+
+ return subscription.cancelAtPeriodEnd ? "cancellation_scheduled" : "active";
+};
+
+/** Whether a purchase is finished, so polling for it can stop. */
+export const isPurchaseSettled = (purchase: {
+ fulfillmentStatus: FulfillmentStatus;
+ paymentStatus: PurchasePaymentStatus;
+}): boolean =>
+ purchase.paymentStatus !== "awaiting_payment" &&
+ purchase.paymentStatus !== "processing" &&
+ (purchase.paymentStatus !== "paid" ||
+ purchase.fulfillmentStatus === "fulfilled" ||
+ purchase.fulfillmentStatus === "failed");
diff --git a/packages/vitnode/src/routes.tsx b/packages/vitnode/src/routes.tsx
index 7dbee423ae..2b14f0e162 100644
--- a/packages/vitnode/src/routes.tsx
+++ b/packages/vitnode/src/routes.tsx
@@ -14,6 +14,12 @@ import { normalizeAdminFilesRouteSearch } from "./tanstack/admin/files/route-sea
import { ADMIN_INTEGRATIONS_NAMESPACES } from "./tanstack/admin/integrations/route";
import { ADMIN_NAVIGATION_NAMESPACES } from "./tanstack/admin/navigation/route";
import { ADMIN_NOTIFICATIONS_NAMESPACES } from "./tanstack/admin/notifications/route";
+import { ADMIN_PAYMENTS_NAMESPACES } from "./tanstack/admin/payments/route";
+import {
+ normalizeEventsRouteSearch,
+ normalizePurchasesRouteSearch,
+ normalizeSubscriptionsRouteSearch,
+} from "./tanstack/admin/payments/route-search";
import { ADMIN_QUEUE_NAMESPACES } from "./tanstack/admin/queue/route";
import { normalizeQueueRouteSearch } from "./tanstack/admin/queue/route-search";
import { ADMIN_ROLES_NAMESPACES } from "./tanstack/admin/roles/route";
@@ -48,6 +54,7 @@ import { MY_FILES_NAMESPACES } from "./tanstack/files/route";
import { normalizeMyFilesRouteSearch } from "./tanstack/files/route-search";
import { NOTIFICATIONS_NAMESPACES } from "./tanstack/notifications/namespaces";
import { normalizeNotificationsRouteSearch } from "./tanstack/notifications/route-search";
+import { BILLING_NAMESPACES } from "./tanstack/payments/billing-route";
import {
AuthPendingSkeleton,
CardsPendingSkeleton,
@@ -179,6 +186,12 @@ export const routes = defineRoutes([
component: lazy(() => import("./pages/settings/ai")),
pendingComponent: () => ,
}),
+
+ page("billing", {
+ component: lazy(() => import("./pages/settings/billing")),
+ messages: BILLING_NAMESPACES,
+ pendingComponent: () => ,
+ }),
],
}),
@@ -255,6 +268,37 @@ export const routes = defineRoutes([
search: normalizeSearchIndexRouteSearch,
}),
+ page("/admin/core/payments", {
+ area: "admin",
+ component: lazy(() => import("./pages/admin/payments/index")),
+ messages: ADMIN_PAYMENTS_NAMESPACES,
+ pendingComponent: TablePendingSkeleton,
+ search: normalizePurchasesRouteSearch,
+ }),
+
+ page("/admin/core/payments/subscriptions", {
+ area: "admin",
+ component: lazy(() => import("./pages/admin/payments/subscriptions")),
+ messages: ADMIN_PAYMENTS_NAMESPACES,
+ pendingComponent: TablePendingSkeleton,
+ search: normalizeSubscriptionsRouteSearch,
+ }),
+
+ page("/admin/core/payments/events", {
+ area: "admin",
+ component: lazy(() => import("./pages/admin/payments/events")),
+ messages: ADMIN_PAYMENTS_NAMESPACES,
+ pendingComponent: TablePendingSkeleton,
+ search: normalizeEventsRouteSearch,
+ }),
+
+ page("/admin/core/payments/purchases/:id", {
+ area: "admin",
+ component: lazy(() => import("./pages/admin/payments/purchase")),
+ messages: ADMIN_PAYMENTS_NAMESPACES,
+ pendingComponent: FormPendingSkeleton,
+ }),
+
page("/admin/core/debug", {
area: "admin",
component: lazy(() => import("./pages/admin/debug")),
diff --git a/packages/vitnode/src/tanstack/admin/index.ts b/packages/vitnode/src/tanstack/admin/index.ts
index 2a1ca0ec54..5af4144dcb 100644
--- a/packages/vitnode/src/tanstack/admin/index.ts
+++ b/packages/vitnode/src/tanstack/admin/index.ts
@@ -80,6 +80,8 @@ export type {
export type {
AdminNavBundle,
AdminNavConfig,
+ AdminNavFeature,
+ AdminNavFeatures,
AdminNavGroupDeclaration,
AdminNavItem,
AdminNavItemDeclaration,
diff --git a/packages/vitnode/src/tanstack/admin/nav.tsx b/packages/vitnode/src/tanstack/admin/nav.tsx
index ee3e8ba2aa..d03e630e99 100644
--- a/packages/vitnode/src/tanstack/admin/nav.tsx
+++ b/packages/vitnode/src/tanstack/admin/nav.tsx
@@ -15,6 +15,7 @@ import {
resolveAdminNav,
} from "@/views/admin/layouts/sidebar/nav/nav-model";
+import { useMiddlewareConfigQuery } from "../auth/middleware-config";
import { useAdminPermissions } from "./permissions";
interface AdminNavValue {
@@ -37,11 +38,18 @@ export const AdminNavProvider = ({
declarations?: AdminNavGroupDeclaration[];
}) => {
const permissions = useAdminPermissions();
+ const { data: config } = useMiddlewareConfigQuery();
+ const payments = config.payments;
const t = useTranslations() as unknown as AdminNavTranslator;
const value = React.useMemo(() => {
- const nav = resolveAdminNav({ declarations, permissions, t });
+ const nav = resolveAdminNav({
+ declarations,
+ features: { payments },
+ permissions,
+ t,
+ });
return {
nav,
@@ -51,7 +59,7 @@ export const AdminNavProvider = ({
...adminSearchOnlyItems({ permissions, t }),
],
};
- }, [declarations, permissions, t]);
+ }, [declarations, payments, permissions, t]);
return {children};
};
diff --git a/packages/vitnode/src/tanstack/admin/payments/index.ts b/packages/vitnode/src/tanstack/admin/payments/index.ts
new file mode 100644
index 0000000000..7b698763bf
--- /dev/null
+++ b/packages/vitnode/src/tanstack/admin/payments/index.ts
@@ -0,0 +1,3 @@
+export * from "./route";
+export * from "./route-search";
+export * from "./screen";
diff --git a/packages/vitnode/src/tanstack/admin/payments/route-search.ts b/packages/vitnode/src/tanstack/admin/payments/route-search.ts
new file mode 100644
index 0000000000..23b632caa2
--- /dev/null
+++ b/packages/vitnode/src/tanstack/admin/payments/route-search.ts
@@ -0,0 +1,135 @@
+import type { AdminTableContract } from "@/views/admin/table/params";
+
+import { asSearchValue } from "@/lib/table-params";
+import {
+ EVENTS_TABLE_CONTRACT,
+ type EventsAdminParams,
+ FULFILLMENT_STATUSES,
+ PAYMENT_PERIOD_FILTERS,
+ type PaymentsListFilters,
+ PURCHASES_TABLE_CONTRACT,
+ type PurchasesAdminParams,
+ SUBSCRIPTIONS_TABLE_CONTRACT,
+ type SubscriptionsAdminParams,
+} from "@/views/admin/views/core/payments/payments-admin-query";
+
+import type { AdminTableRouteSearch } from "../table-search";
+
+import {
+ adminTableRouteParams,
+ adminTableSearchFrom,
+ adminTableSearchParams,
+ normalizeAdminTableSearch,
+} from "../table-search";
+
+type Unchecked = Record;
+
+const FILTER_KEYS = ["currency", "fulfillment", "period", "provider"] as const;
+
+/** One comma-separated filter, each value checked and sorted for a stable key. */
+const readList = (
+ raw: unknown,
+ accept: (value: string) => boolean,
+): string | undefined => {
+ const value = asSearchValue(raw);
+ if (value === undefined) return undefined;
+
+ const values = [...new Set(value.split(",").filter(accept))].sort((a, b) =>
+ a.localeCompare(b),
+ );
+
+ return values.length ? values.join(",") : undefined;
+};
+
+/** The payments filters on top of the shared table contract - all validated. */
+const paymentFilters = (
+ input: Unchecked,
+ { fulfillment }: { fulfillment: boolean },
+): PaymentsListFilters => {
+ const record = input as Record;
+ const filters: PaymentsListFilters = {
+ currency: readList(record.currency, value => /^[A-Z]{3}$/.test(value)),
+ fulfillment: fulfillment
+ ? readList(record.fulfillment, value =>
+ (FULFILLMENT_STATUSES as readonly string[]).includes(value),
+ )
+ : undefined,
+ period: readList(record.period, value =>
+ (PAYMENT_PERIOD_FILTERS as readonly string[]).includes(value),
+ ),
+ provider: readList(record.provider, value =>
+ /^[a-z0-9-]{1,32}$/.test(value),
+ ),
+ };
+
+ return Object.fromEntries(
+ Object.entries(filters).filter(([, value]) => value !== undefined),
+ );
+};
+
+const searchWithFilters = (
+ contract: AdminTableContract,
+ options: { fulfillment: boolean },
+) => {
+ const normalize = (input: Unchecked) => ({
+ ...normalizeAdminTableSearch(input, contract),
+ ...paymentFilters(input, options),
+ });
+
+ return {
+ from: (nextSearch: string) =>
+ normalize(Object.fromEntries(new URLSearchParams(nextSearch))),
+ normalize,
+ params: (input: Unchecked) => ({
+ ...adminTableRouteParams(input, contract),
+ ...paymentFilters(input, options),
+ }),
+ searchParams: (input: Unchecked) => {
+ const params = adminTableSearchParams(input, contract);
+ const filters = paymentFilters(input, options);
+ for (const key of FILTER_KEYS) {
+ const value = filters[key];
+ if (value !== undefined) params.set(key, value);
+ }
+
+ return params;
+ },
+ };
+};
+
+export type PaymentsTableRouteSearch = AdminTableRouteSearch &
+ PaymentsListFilters;
+
+export const purchasesSearch = searchWithFilters(PURCHASES_TABLE_CONTRACT, {
+ fulfillment: true,
+});
+export const subscriptionsSearch = searchWithFilters(
+ SUBSCRIPTIONS_TABLE_CONTRACT,
+ {
+ fulfillment: false,
+ },
+);
+
+export const normalizePurchasesRouteSearch = (
+ input: Unchecked,
+): PaymentsTableRouteSearch => purchasesSearch.normalize(input);
+export const purchasesRouteParams = (input: Unchecked): PurchasesAdminParams =>
+ purchasesSearch.params(input);
+
+export const normalizeSubscriptionsRouteSearch = (
+ input: Unchecked,
+): PaymentsTableRouteSearch => subscriptionsSearch.normalize(input);
+export const subscriptionsRouteParams = (
+ input: Unchecked,
+): SubscriptionsAdminParams => subscriptionsSearch.params(input);
+
+export const normalizeEventsRouteSearch = (
+ input: Unchecked,
+): AdminTableRouteSearch =>
+ normalizeAdminTableSearch(input, EVENTS_TABLE_CONTRACT);
+export const eventsRouteParams = (input: Unchecked): EventsAdminParams =>
+ adminTableRouteParams<"receivedAt">(input, EVENTS_TABLE_CONTRACT);
+export const eventsSearchParams = (input: Unchecked): URLSearchParams =>
+ adminTableSearchParams(input, EVENTS_TABLE_CONTRACT);
+export const eventsSearchFrom = (nextSearch: string): AdminTableRouteSearch =>
+ adminTableSearchFrom(nextSearch, EVENTS_TABLE_CONTRACT);
diff --git a/packages/vitnode/src/tanstack/admin/payments/route.ts b/packages/vitnode/src/tanstack/admin/payments/route.ts
new file mode 100644
index 0000000000..d97b71b93a
--- /dev/null
+++ b/packages/vitnode/src/tanstack/admin/payments/route.ts
@@ -0,0 +1,154 @@
+import { notFound } from "@tanstack/react-router";
+
+import type { PluginRouteTranslator } from "@/routing";
+import type {
+ EventsAdminParams,
+ PurchasesAdminParams,
+ SubscriptionsAdminParams,
+} from "@/views/admin/views/core/payments/payments-admin-query";
+
+import { requirePaymentsEnabled } from "@/tanstack/payments/require-enabled";
+import {
+ adminPurchaseQueryOptions,
+ adminPurchasesQueryOptions,
+ adminSubscriptionsQueryOptions,
+ adminWebhookEventsQueryOptions,
+ paymentsOverviewQueryOptions,
+} from "@/views/admin/views/core/payments/payments-admin-query";
+
+import type { AdminScreenContext } from "../screen";
+
+import { requireAdminPermission } from "../screen";
+import { hasAdminPermission } from "../state";
+
+/** Every payments screen renders amounts and states from both trees. */
+export const ADMIN_PAYMENTS_NAMESPACES = [
+ "admin.payments",
+ "core.global",
+ "core.payments",
+] as const;
+
+const VIEW = { module: "payments", permission: "can_view" } as const;
+const RETRY = { module: "payments", permission: "can_retry" } as const;
+
+interface ScreenData {
+ canRetry: boolean;
+ description: string;
+ title: string;
+}
+
+export interface AdminPaymentsRouteData extends ScreenData {
+ params: PurchasesAdminParams;
+}
+
+export interface AdminSubscriptionsRouteData extends ScreenData {
+ params: SubscriptionsAdminParams;
+}
+
+export interface AdminWebhookEventsRouteData extends ScreenData {
+ params: EventsAdminParams;
+}
+
+export interface AdminPaymentRouteData extends ScreenData {
+ id: string;
+}
+
+type Loader = AdminScreenContext & {
+ params: TParams;
+ t: PluginRouteTranslator;
+};
+
+const warmShared = async ({ queryClient }: AdminScreenContext) => {
+ await queryClient.query({
+ ...paymentsOverviewQueryOptions(),
+ staleTime: "static",
+ });
+};
+
+export const loadAdminPaymentsRoute = async (
+ context: Loader,
+): Promise => {
+ requireAdminPermission(context.adminAccess, VIEW);
+ await requirePaymentsEnabled(context.queryClient);
+
+ await Promise.all([
+ warmShared(context),
+ context.queryClient.query({
+ ...adminPurchasesQueryOptions({ params: context.params }),
+ staleTime: "static",
+ }),
+ ]);
+
+ return {
+ canRetry: hasAdminPermission(context.adminAccess, RETRY),
+ description: context.t("admin.payments.desc"),
+ params: context.params,
+ title: context.t("admin.payments.title"),
+ };
+};
+
+export const loadAdminSubscriptionsRoute = async (
+ context: Loader,
+): Promise => {
+ requireAdminPermission(context.adminAccess, VIEW);
+ await requirePaymentsEnabled(context.queryClient);
+
+ await Promise.all([
+ warmShared(context),
+ context.queryClient.query({
+ ...adminSubscriptionsQueryOptions({ params: context.params }),
+ staleTime: "static",
+ }),
+ ]);
+
+ return {
+ canRetry: hasAdminPermission(context.adminAccess, RETRY),
+ description: context.t("admin.payments.subscriptions.desc"),
+ params: context.params,
+ title: context.t("admin.payments.subscriptions.title"),
+ };
+};
+
+export const loadAdminWebhookEventsRoute = async (
+ context: Loader,
+): Promise => {
+ requireAdminPermission(context.adminAccess, VIEW);
+ await requirePaymentsEnabled(context.queryClient);
+
+ await context.queryClient.query({
+ ...adminWebhookEventsQueryOptions({ params: context.params }),
+ staleTime: "static",
+ });
+
+ return {
+ canRetry: hasAdminPermission(context.adminAccess, RETRY),
+ description: context.t("admin.payments.events.desc"),
+ params: context.params,
+ title: context.t("admin.payments.events.title"),
+ };
+};
+
+export const loadAdminPaymentRoute = async (
+ context: Loader<{ id: string }>,
+): Promise => {
+ requireAdminPermission(context.adminAccess, VIEW);
+ await requirePaymentsEnabled(context.queryClient);
+
+ const { id } = context.params;
+ if (!/^[0-9a-f-]{36}$/i.test(id)) {
+ // oxlint-disable-next-line typescript/only-throw-error
+ throw notFound();
+ }
+
+ const detail = await context.queryClient.query({
+ ...adminPurchaseQueryOptions({ id }),
+ staleTime: "static",
+ });
+
+ return {
+ canRetry: hasAdminPermission(context.adminAccess, RETRY),
+ description: detail.purchase.pluginId,
+ id,
+ title: `${detail.purchase.offerName} - ${context.t("admin.payments.detail.title")}`,
+ };
+};
diff --git a/packages/vitnode/src/tanstack/admin/payments/screen.tsx b/packages/vitnode/src/tanstack/admin/payments/screen.tsx
new file mode 100644
index 0000000000..31df6119e0
--- /dev/null
+++ b/packages/vitnode/src/tanstack/admin/payments/screen.tsx
@@ -0,0 +1,196 @@
+import { useSuspenseQuery } from "@tanstack/react-query";
+import React from "react";
+
+import type { DataTableNavigation } from "@/components/table/navigation";
+
+import { DataTableNavigationProvider } from "@/components/table/navigation";
+import { PageTitle } from "@/components/ui/page-title";
+import { EventsTableContent } from "@/views/admin/views/core/payments/events-table-content";
+import { PaymentsOverviewContent } from "@/views/admin/views/core/payments/overview-content";
+import {
+ adminPurchaseQueryOptions,
+ adminPurchasesQueryOptions,
+ adminSubscriptionsQueryOptions,
+ adminWebhookEventsQueryOptions,
+ paymentsOverviewQueryOptions,
+} from "@/views/admin/views/core/payments/payments-admin-query";
+import { PurchaseDetailContent } from "@/views/admin/views/core/payments/purchase-detail-content";
+import { PurchasesTableContent } from "@/views/admin/views/core/payments/purchases-table-content";
+import { SubscriptionsTableContent } from "@/views/admin/views/core/payments/subscriptions-table-content";
+import { paymentsSettingsQueryOptions } from "@/views/payments/payments-query";
+
+import type {
+ AdminTableNavigate,
+ AdminTableRouteSearch,
+} from "../table-search";
+import type {
+ AdminPaymentRouteData,
+ AdminPaymentsRouteData,
+ AdminSubscriptionsRouteData,
+ AdminWebhookEventsRouteData,
+} from "./route";
+import type { PaymentsTableRouteSearch } from "./route-search";
+
+import { RouteMessages } from "../../i18n/route-messages";
+import { ADMIN_PAYMENTS_NAMESPACES } from "./route";
+import {
+ eventsSearchFrom,
+ eventsSearchParams,
+ purchasesSearch,
+ subscriptionsSearch,
+} from "./route-search";
+
+type Search = Record;
+
+const useTableNavigation = (
+ navigate: AdminTableNavigate,
+ search: Search,
+ {
+ from,
+ searchParams,
+ }: {
+ from: (next: string) => TSearch;
+ searchParams: (input: Search) => URLSearchParams;
+ },
+) =>
+ React.useMemo(
+ () => ({
+ navigate: async nextSearch => {
+ await navigate({ resetScroll: false, search: from(nextSearch) });
+ },
+ searchParams: searchParams(search),
+ }),
+ [from, navigate, search, searchParams],
+ );
+
+/** The filter options: enabled currencies, plus any that history still holds. */
+const useFilterOptions = () => {
+ const { data: overview } = useSuspenseQuery(paymentsOverviewQueryOptions());
+ const { data: settings } = useSuspenseQuery(paymentsSettingsQueryOptions());
+
+ return {
+ currencies: [
+ ...new Set([
+ ...settings.currencies.map(currency => currency.code),
+ ...overview.totals.map(total => total.currency),
+ ]),
+ ].sort(),
+ overview,
+ providers: overview.providers.map(provider => provider.id),
+ };
+};
+
+const Frame = ({
+ children,
+ description,
+ title,
+}: {
+ children: React.ReactNode;
+ description: string;
+ title: string;
+}) => (
+
+
+
+);
+
+export const AdminPaymentsRouteContent = ({
+ description,
+ navigate,
+ params,
+ search,
+ title,
+}: AdminPaymentsRouteData & {
+ navigate: AdminTableNavigate;
+ search: Search;
+}) => {
+ const { currencies, overview, providers } = useFilterOptions();
+ const { data } = useSuspenseQuery(adminPurchasesQueryOptions({ params }));
+ const navigation = useTableNavigation(navigate, search, purchasesSearch);
+
+ return (
+
+
+
+
+
+
+ );
+};
+
+export const AdminSubscriptionsRouteContent = ({
+ description,
+ navigate,
+ params,
+ search,
+ title,
+}: AdminSubscriptionsRouteData & {
+ navigate: AdminTableNavigate;
+ search: Search;
+}) => {
+ const { currencies, providers } = useFilterOptions();
+ const { data } = useSuspenseQuery(adminSubscriptionsQueryOptions({ params }));
+ const navigation = useTableNavigation(navigate, search, subscriptionsSearch);
+
+ return (
+
+
+
+
+
+ );
+};
+
+const EVENTS_SEARCH = {
+ from: eventsSearchFrom,
+ searchParams: eventsSearchParams,
+};
+
+export const AdminWebhookEventsRouteContent = ({
+ canRetry,
+ description,
+ navigate,
+ params,
+ search,
+ title,
+}: AdminWebhookEventsRouteData & {
+ navigate: AdminTableNavigate;
+ search: Search;
+}) => {
+ const { data } = useSuspenseQuery(adminWebhookEventsQueryOptions({ params }));
+ const navigation = useTableNavigation(navigate, search, EVENTS_SEARCH);
+
+ return (
+
+
+
+
+
+ );
+};
+
+export const AdminPaymentRouteContent = ({
+ canRetry,
+ description,
+ id,
+ title,
+}: AdminPaymentRouteData) => {
+ const { data } = useSuspenseQuery(adminPurchaseQueryOptions({ id }));
+
+ return (
+
+
+
+ );
+};
diff --git a/packages/vitnode/src/tanstack/auth/auth-methods.test.ts b/packages/vitnode/src/tanstack/auth/auth-methods.test.ts
index c35484e09e..1fc77c9df7 100644
--- a/packages/vitnode/src/tanstack/auth/auth-methods.test.ts
+++ b/packages/vitnode/src/tanstack/auth/auth-methods.test.ts
@@ -13,6 +13,7 @@ const config = (
navigation: [],
passkeys: true,
password: true,
+ payments: false,
sso: [],
...overrides,
});
diff --git a/packages/vitnode/src/tanstack/auth/middleware-config.test.ts b/packages/vitnode/src/tanstack/auth/middleware-config.test.ts
index 2d82e1507a..b33dd18f2c 100644
--- a/packages/vitnode/src/tanstack/auth/middleware-config.test.ts
+++ b/packages/vitnode/src/tanstack/auth/middleware-config.test.ts
@@ -44,6 +44,7 @@ describe("loadMiddlewareConfig", () => {
navigation: [],
passkeys: false,
password: true,
+ payments: false,
sso: [],
});
const queryClient = seeded(known);
@@ -80,6 +81,7 @@ describe("middlewareConfigQueryOptions", () => {
navigation: [],
passkeys: false,
password: true,
+ payments: false,
sso: [],
}),
),
diff --git a/packages/vitnode/src/tanstack/auth/middleware-config.ts b/packages/vitnode/src/tanstack/auth/middleware-config.ts
index 8525555e1f..c158a2225b 100644
--- a/packages/vitnode/src/tanstack/auth/middleware-config.ts
+++ b/packages/vitnode/src/tanstack/auth/middleware-config.ts
@@ -25,6 +25,7 @@ export const UNKNOWN_MIDDLEWARE_CONFIG: MiddlewareConfigState = Object.freeze({
navigation: [],
passkeys: false,
password: true,
+ payments: false,
bottomBar: [],
sso: [],
});
diff --git a/packages/vitnode/src/tanstack/auth/recovery.test.ts b/packages/vitnode/src/tanstack/auth/recovery.test.ts
index b9dce25ab2..8d51957037 100644
--- a/packages/vitnode/src/tanstack/auth/recovery.test.ts
+++ b/packages/vitnode/src/tanstack/auth/recovery.test.ts
@@ -203,6 +203,7 @@ describe("whether this deployment has password recovery at all", () => {
isEmail: false,
passkeys: false,
password: true,
+ payments: false,
navigation: [],
bottomBar: [],
sso: [],
@@ -215,6 +216,7 @@ describe("whether this deployment has password recovery at all", () => {
isEmail: false,
passkeys: false,
password: true,
+ payments: false,
navigation: [],
bottomBar: [],
sso: [],
diff --git a/packages/vitnode/src/tanstack/layout/main-shell.test.ts b/packages/vitnode/src/tanstack/layout/main-shell.test.ts
index 322d4c32e7..282831e396 100644
--- a/packages/vitnode/src/tanstack/layout/main-shell.test.ts
+++ b/packages/vitnode/src/tanstack/layout/main-shell.test.ts
@@ -48,6 +48,7 @@ const KNOWN_CONFIG = knownMiddlewareConfig({
navigation: [],
passkeys: false,
password: true,
+ payments: false,
sso: [],
});
diff --git a/packages/vitnode/src/tanstack/payments/billing-route.ts b/packages/vitnode/src/tanstack/payments/billing-route.ts
new file mode 100644
index 0000000000..60dfe7be6c
--- /dev/null
+++ b/packages/vitnode/src/tanstack/payments/billing-route.ts
@@ -0,0 +1,35 @@
+import type { QueryClient } from "@tanstack/react-query";
+
+import {
+ purchasesQueryOptions,
+ subscriptionsQueryOptions,
+} from "@/views/payments/payments-query";
+
+import { requirePaymentsEnabled } from "./require-enabled";
+
+/** Translation namespaces the billing screen renders from. */
+export const BILLING_NAMESPACES = ["core.payments"] as const;
+
+/** Warms everything `/settings/billing` shows, so it renders without a spinner. */
+export const loadBillingRoute = async ({
+ queryClient,
+ userId,
+}: {
+ queryClient: QueryClient;
+ userId: number;
+}): Promise<{ userId: number }> => {
+ await requirePaymentsEnabled(queryClient);
+
+ await Promise.all([
+ queryClient.infiniteQuery({
+ ...purchasesQueryOptions({ userId }),
+ staleTime: "static",
+ }),
+ queryClient.infiniteQuery({
+ ...subscriptionsQueryOptions({ userId }),
+ staleTime: "static",
+ }),
+ ]);
+
+ return { userId };
+};
diff --git a/packages/vitnode/src/tanstack/payments/index.ts b/packages/vitnode/src/tanstack/payments/index.ts
new file mode 100644
index 0000000000..fbbbb49e1e
--- /dev/null
+++ b/packages/vitnode/src/tanstack/payments/index.ts
@@ -0,0 +1,31 @@
+/**
+ * Payments UI for the host app and for plugins: query options, the money
+ * formatter, state badges, checkout/portal hooks and the return poller. Every
+ * request goes through the universal fetcher; nothing here touches the API
+ * config or a provider.
+ */
+export { BILLING_NAMESPACES, loadBillingRoute } from "./billing-route";
+export { requirePaymentsEnabled } from "./require-enabled";
+
+export { BillingContent } from "@/views/payments/billing/billing-content";
+export {
+ MoneyText,
+ useCurrencyDisplay,
+ useMoneyFormatter,
+} from "@/views/payments/money-text";
+export * from "@/views/payments/payments-query";
+export {
+ PurchaseStateBadge,
+ SubscriptionStateBadge,
+} from "@/views/payments/state-badges";
+export type { CheckoutChoice } from "@/views/payments/use-checkout";
+export {
+ useBillingPortal,
+ useCancelPurchase,
+ useStartCheckout,
+} from "@/views/payments/use-checkout";
+export {
+ PURCHASE_RETURN_MAX_CHECKS,
+ purchaseReturnDelay,
+ usePurchaseReturn,
+} from "@/views/payments/use-purchase-return";
diff --git a/packages/vitnode/src/tanstack/payments/require-enabled.ts b/packages/vitnode/src/tanstack/payments/require-enabled.ts
new file mode 100644
index 0000000000..8077aca41a
--- /dev/null
+++ b/packages/vitnode/src/tanstack/payments/require-enabled.ts
@@ -0,0 +1,25 @@
+import type { QueryClient } from "@tanstack/react-query";
+
+import { notFound } from "@tanstack/react-router";
+
+import type { PaymentsSettings } from "@/views/payments/payments-query";
+
+import { paymentsSettingsQueryOptions } from "@/views/payments/payments-query";
+
+/**
+ * Loads the public payments settings, or answers 404 when no provider is
+ * configured - every payments screen is hidden then, not shown empty.
+ */
+export const requirePaymentsEnabled = async (
+ queryClient: QueryClient,
+): Promise => {
+ const settings = await queryClient.query({
+ ...paymentsSettingsQueryOptions(),
+ staleTime: "static",
+ });
+
+ // oxlint-disable-next-line typescript/only-throw-error
+ if (!settings.enabled) throw notFound();
+
+ return settings;
+};
diff --git a/packages/vitnode/src/tests/payments.ts b/packages/vitnode/src/tests/payments.ts
new file mode 100644
index 0000000000..71a55ba410
--- /dev/null
+++ b/packages/vitnode/src/tests/payments.ts
@@ -0,0 +1,687 @@
+import type { Context, MiddlewareHandler } from "hono";
+
+import { and, eq, lte } from "drizzle-orm";
+import { camelCase, uniqueIndex } from "drizzle-orm/pg-core";
+import { createTranslator } from "use-intl";
+
+import type { EnvVitNode } from "@/api/middlewares/global.middleware";
+import type { PaymentsConfig } from "@/payments/config";
+import type { PaymentOffer } from "@/payments/offer";
+import type {
+ PaymentProvider,
+ ProviderCheckout,
+ ProviderCheckoutInput,
+ ProviderInvoice,
+ ProviderPayment,
+ ProviderSubscription,
+ ProviderWebhookEvent,
+} from "@/payments/provider";
+
+import { QueueModel } from "@/api/models/queue";
+import {
+ fulfillPaymentTask,
+ processPaymentEventTask,
+} from "@/api/modules/payments/tasks/payments.tasks";
+import { processQueueTasksByIds } from "@/api/modules/queue/helpers/process-queue-tasks";
+import { core_cron } from "@/database/cron";
+import { core_files } from "@/database/files";
+import { core_languages } from "@/database/languages";
+import * as paymentTables from "@/database/payments";
+import { core_queue } from "@/database/queue";
+import { core_roles } from "@/database/roles";
+import { core_users } from "@/database/users";
+import { createPaymentsRegistry } from "@/payments/config";
+import { definePaymentOffer } from "@/payments/offer";
+import {
+ PaymentProviderError,
+ PaymentWebhookSignatureError,
+} from "@/payments/provider";
+
+import { createTestCache } from "./cache";
+import { createTestDatabase, type TestDatabaseHandle } from "./postgres";
+
+/**
+ * A plugin's own access table, shaped like the example plugin's: one row per
+ * user and offer, unique, so a duplicate grant is a constraint violation
+ * rather than a silent second row.
+ */
+export const test_payments_access = camelCase.table(
+ "test_payments_access",
+ t => ({
+ id: t.serial().primaryKey(),
+ userId: t.integer().notNull(),
+ offerId: t.varchar({ length: 64 }).notNull(),
+ purchaseId: t.varchar({ length: 64 }).notNull(),
+ accessUntil: t.timestamp(),
+ revokedAt: t.timestamp(),
+ }),
+ t => [uniqueIndex("test_payments_access_unique").on(t.userId, t.offerId)],
+);
+
+const PLUGIN_ID = "@acme/test";
+
+/** The value, or a test failure naming what was missing. */
+export const must = (value: null | T | undefined, what = "value"): T => {
+ if (value === null || value === undefined) {
+ throw new Error(`Expected a ${what}.`);
+ }
+
+ return value;
+};
+
+/** A deterministic stand-in for a hosted-checkout provider. Nothing leaves the process. */
+export const createFakeProvider = (id = "fake") => {
+ let seq = 0;
+ const next = (prefix: string) => `${prefix}_${++seq}`;
+
+ const checkouts = new Map<
+ string,
+ ProviderCheckout & { input: ProviderCheckoutInput }
+ >();
+ const byIdempotencyKey = new Map();
+ const subscriptions = new Map();
+ const invoices = new Map();
+ const payments = new Map();
+ const createCalls: {
+ idempotencyKey: string;
+ input: ProviderCheckoutInput;
+ }[] = [];
+ const customerCalls: { idempotencyKey: string; userId: number }[] = [];
+ const failures: { error: Error; operation: string }[] = [];
+ let webhookQueue: null | ProviderWebhookEvent = null;
+
+ const failIfAsked = (operation: string) => {
+ const index = failures.findIndex(
+ failure => failure.operation === operation,
+ );
+ if (index === -1) return;
+ const [failure] = failures.splice(index, 1);
+ throw failure.error;
+ };
+
+ const provider: PaymentProvider = {
+ checkAmount: ({ amount }) => (amount > 99_999_999 ? "Too large." : null),
+ checkout: {
+ create: async (input, { idempotencyKey }) => {
+ createCalls.push({ idempotencyKey, input });
+ const replay = byIdempotencyKey.get(idempotencyKey);
+
+ // The request reached the provider and then the answer was lost.
+ const lost = failures.findIndex(
+ failure => failure.operation === "create:lost",
+ );
+ if (lost !== -1 && !replay) {
+ const [failure] = failures.splice(lost, 1);
+ const checkoutId = next("cs");
+ byIdempotencyKey.set(idempotencyKey, checkoutId);
+ checkouts.set(checkoutId, {
+ amountTotal: input.item.amount,
+ currency: input.item.currency,
+ customerId: input.customerId,
+ expiresAt: input.expiresAt,
+ id: checkoutId,
+ input,
+ paymentId: null,
+ paymentStatus: "unpaid",
+ reference: input.reference,
+ status: "open",
+ subscriptionId: null,
+ url: `https://pay.example/${checkoutId}`,
+ });
+ throw failure.error;
+ }
+
+ failIfAsked("create");
+
+ if (replay) {
+ const { input: _input, ...existing } = must(checkouts.get(replay));
+
+ return await Promise.resolve(existing);
+ }
+
+ const checkoutId = next("cs");
+ byIdempotencyKey.set(idempotencyKey, checkoutId);
+ const checkout = {
+ amountTotal: input.item.amount,
+ currency: input.item.currency,
+ customerId: input.customerId,
+ expiresAt: input.expiresAt,
+ id: checkoutId,
+ input,
+ paymentId: null,
+ paymentStatus: "unpaid" as const,
+ reference: input.reference,
+ status: "open" as const,
+ subscriptionId: null,
+ url: `https://pay.example/${checkoutId}`,
+ };
+ checkouts.set(checkoutId, checkout);
+ const { input: _ignored, ...result } = checkout;
+
+ return await Promise.resolve(result);
+ },
+ expire: async checkoutId => {
+ failIfAsked("expire");
+ const checkout = must(checkouts.get(checkoutId));
+ if (checkout.status === "complete") {
+ throw new PaymentProviderError(
+ "rejected",
+ "Checkout is already complete.",
+ );
+ }
+ checkout.status = "expired";
+ checkout.url = null;
+ const { input: _input, ...result } = checkout;
+
+ return await Promise.resolve(result);
+ },
+ retrieve: async checkoutId => {
+ failIfAsked("retrieve");
+ const found = checkouts.get(checkoutId);
+ if (!found)
+ throw new PaymentProviderError("not_found", "No such checkout.");
+ const { input: _input, ...result } = found;
+
+ return await Promise.resolve(result);
+ },
+ },
+ currencies: ["PLN", "USD", "EUR", "JPY"],
+ customers: {
+ create: async ({ userId }, { idempotencyKey }) => {
+ customerCalls.push({ idempotencyKey, userId });
+ failIfAsked("customer");
+
+ return await Promise.resolve({ customerId: `cus_${userId}` });
+ },
+ },
+ id,
+ name: "Fake",
+ payments: {
+ retrieve: async paymentId => {
+ failIfAsked("payment");
+ const payment = payments.get(paymentId);
+ if (!payment)
+ throw new PaymentProviderError("not_found", "No such payment.");
+
+ return await Promise.resolve(structuredClone(payment));
+ },
+ },
+ portal: {
+ createSession: async ({ customerId, returnUrl }) =>
+ await Promise.resolve({
+ url: `https://portal.example/${customerId}?return=${encodeURIComponent(returnUrl)}`,
+ }),
+ },
+ scope: "test",
+ subscriptions: {
+ retrieve: async subscriptionId => {
+ failIfAsked("subscription");
+ const subscription = subscriptions.get(subscriptionId);
+ if (!subscription)
+ throw new PaymentProviderError("not_found", "No such subscription.");
+
+ return await Promise.resolve(structuredClone(subscription));
+ },
+ retrieveInvoice: async invoiceId => {
+ failIfAsked("invoice");
+ const invoice = invoices.get(invoiceId);
+ if (!invoice)
+ throw new PaymentProviderError("not_found", "No such invoice.");
+
+ return await Promise.resolve(structuredClone(invoice));
+ },
+ },
+ webhooks: {
+ verify: async ({ headers }) => {
+ if (headers.get("x-fake-signature") !== "valid") {
+ throw new PaymentWebhookSignatureError("Bad signature.");
+ }
+ const event = webhookQueue;
+ webhookQueue = null;
+
+ return await Promise.resolve(must(event));
+ },
+ },
+ };
+
+ const DAY = 24 * 60 * 60 * 1000;
+
+ return {
+ checkouts,
+ createCalls,
+ customerCalls,
+ /** Make the next call to `operation` throw `error`. */
+ failNext: (operation: string, error: Error) => {
+ failures.push({ error, operation });
+ },
+ invoices,
+ nextWebhook: (event: ProviderWebhookEvent) => {
+ webhookQueue = event;
+ },
+ payments,
+ provider,
+ subscriptions,
+
+ /** The buyer completes the hosted page. */
+ complete: (
+ checkoutId: string,
+ outcome: "failed" | "paid" | "unpaid" = "paid",
+ { now = new Date() }: { now?: Date } = {},
+ ) => {
+ const checkout = must(checkouts.get(checkoutId));
+ checkout.status = "complete";
+ checkout.paymentStatus = outcome;
+ checkout.url = null;
+
+ if (checkout.input.mode === "one_time") {
+ checkout.paymentId = `pi_${checkoutId}`;
+ payments.set(checkout.paymentId, {
+ amount: checkout.input.item.amount,
+ amountRefunded: 0,
+ currency: checkout.input.item.currency,
+ disputes: [],
+ id: checkout.paymentId,
+ refunds: [],
+ });
+
+ return { checkout };
+ }
+
+ const subscriptionId = `sub_${checkoutId}`;
+ const invoiceId = `in_${checkoutId}_1`;
+ const periodEnd = new Date(
+ now.getTime() +
+ (checkout.input.item.interval === "year" ? 365 : 30) * DAY,
+ );
+ checkout.subscriptionId = subscriptionId;
+ subscriptions.set(subscriptionId, {
+ amount: checkout.input.item.amount,
+ cancelAt: null,
+ cancelAtPeriodEnd: false,
+ canceledAt: null,
+ currency: checkout.input.item.currency,
+ currentPeriodEnd: periodEnd,
+ customerId: checkout.input.customerId,
+ endedAt: null,
+ id: subscriptionId,
+ interval: checkout.input.item.interval ?? "month",
+ latestInvoiceId: invoiceId,
+ priceId: `price_${checkoutId}`,
+ providerStatus: outcome === "paid" ? "active" : "incomplete",
+ reference: checkout.input.reference,
+ status: outcome === "paid" ? "active" : "incomplete",
+ });
+ invoices.set(invoiceId, {
+ amountDue: checkout.input.item.amount,
+ amountPaid: outcome === "paid" ? checkout.input.item.amount : 0,
+ billingReason: "subscription_create",
+ currency: checkout.input.item.currency,
+ id: invoiceId,
+ paidAt: outcome === "paid" ? now : null,
+ paymentId: outcome === "paid" ? `pi_${invoiceId}` : null,
+ periodEnd,
+ periodStart: now,
+ providerStatus: outcome === "paid" ? "paid" : "open",
+ status: outcome === "paid" ? "paid" : "open",
+ subscriptionId,
+ });
+
+ return { checkout, invoiceId, subscriptionId };
+ },
+
+ expireCheckout: (checkoutId: string) => {
+ const checkout = must(checkouts.get(checkoutId));
+ checkout.status = "expired";
+ checkout.url = null;
+ },
+
+ /** The provider bills the next period. */
+ renew: (
+ subscriptionId: string,
+ outcome: "action_required" | "failed" | "paid",
+ ): string => {
+ const subscription = must(subscriptions.get(subscriptionId));
+ const start = must(subscription.currentPeriodEnd);
+ const end = new Date(
+ start.getTime() + (subscription.interval === "year" ? 365 : 30) * DAY,
+ );
+ const invoiceId = `in_${subscriptionId}_${start.getTime()}`;
+ invoices.set(invoiceId, {
+ amountDue: subscription.amount ?? 0,
+ amountPaid: outcome === "paid" ? (subscription.amount ?? 0) : 0,
+ billingReason: "subscription_cycle",
+ currency: subscription.currency ?? "PLN",
+ id: invoiceId,
+ paidAt: outcome === "paid" ? start : null,
+ paymentId: outcome === "paid" ? `pi_${invoiceId}` : null,
+ periodEnd: end,
+ periodStart: start,
+ providerStatus: outcome === "paid" ? "paid" : "open",
+ status: outcome === "paid" ? "paid" : "open",
+ subscriptionId,
+ });
+ subscription.latestInvoiceId = invoiceId;
+ subscription.currentPeriodEnd = end;
+ subscription.status = outcome === "paid" ? "active" : "past_due";
+ subscription.providerStatus = subscription.status;
+
+ return invoiceId;
+ },
+
+ refund: (
+ paymentId: string,
+ amount: number,
+ status: "pending" | "succeeded" = "succeeded",
+ ) => {
+ const payment = must(payments.get(paymentId));
+ const refundId = `re_${payment.refunds.length + 1}_${paymentId}`;
+ payment.refunds.push({ amount, id: refundId, status });
+ payment.amountRefunded = payment.refunds
+ .filter(refund => refund.status === "succeeded")
+ .reduce((sum, refund) => sum + refund.amount, 0);
+
+ return refundId;
+ },
+ };
+};
+
+export type FakeProvider = ReturnType;
+
+export interface PaymentsHarness {
+ access: (
+ userId: number,
+ ) => Promise<(typeof test_payments_access.$inferSelect)[]>;
+ c: Context;
+ createUser: () => Promise<{
+ email: string;
+ id: number;
+ language: string;
+ name: string;
+ }>;
+ database: TestDatabaseHandle;
+ drainQueue: (now?: Date) => Promise;
+ emitted: { name: string; payload: unknown }[];
+ fake: FakeProvider;
+ forkContext: () => Context;
+ handlerCalls: { kind: string; purchaseId: string }[];
+ /**
+ * Puts the harness services on a real request, as `globalMiddleware` would.
+ * `user` decides who is signed in for each request.
+ */
+ middleware: (options?: {
+ admin?: () => null | { id: number };
+ queue?: () => unknown;
+ user?: () => null | { id: number; language?: string; name?: string };
+ }) => MiddlewareHandler;
+ setHandlerFailure: (error: Error | null) => void;
+}
+
+export const createPaymentsHarness = async ({
+ config,
+ offers: extraOffers = [],
+ payments = true,
+}: {
+ config?: Partial;
+ offers?: PaymentOffer[];
+ payments?: boolean;
+} = {}): Promise => {
+ const database = await createTestDatabase({
+ core_cron,
+ core_files,
+ core_languages,
+ core_queue,
+ core_roles,
+ core_users,
+ ...paymentTables,
+ test_payments_access,
+ });
+ await database.db
+ .insert(core_languages)
+ .values({ code: "en", name: "English", timezone: "UTC" });
+ await database.db.insert(core_roles).values({ id: 1 });
+
+ const fake = createFakeProvider();
+ const emitted: PaymentsHarness["emitted"] = [];
+ const handlerCalls: PaymentsHarness["handlerCalls"] = [];
+ let handlerFailure: Error | null = null;
+
+ const failIfAsked = () => {
+ if (handlerFailure) throw handlerFailure;
+ };
+
+ const lifetime = definePaymentOffer({
+ id: "lifetime",
+ isEligible: async ({ tx, userId }) => {
+ const [owned] = await tx
+ .select({ id: test_payments_access.id })
+ .from(test_payments_access)
+ .where(
+ and(
+ eq(test_payments_access.userId, userId),
+ eq(test_payments_access.offerId, "lifetime"),
+ ),
+ );
+
+ return owned
+ ? { eligible: false, reason: "You already own it." }
+ : { eligible: true };
+ },
+ mode: "one_time",
+ name: "Lifetime supporter",
+ onPaid: async ({ purchase, tx }) => {
+ handlerCalls.push({ kind: "paid", purchaseId: purchase.id });
+ // The write happens before the failure, so a rollback is observable.
+ await tx.insert(test_payments_access).values({
+ offerId: purchase.offerId,
+ purchaseId: purchase.id,
+ userId: must(purchase.userId),
+ });
+ failIfAsked();
+ },
+ onRefunded: async ({ purchase, tx }) => {
+ handlerCalls.push({ kind: "refunded", purchaseId: purchase.id });
+ if (purchase.refundStatus !== "full") return;
+ await tx
+ .delete(test_payments_access)
+ .where(eq(test_payments_access.purchaseId, purchase.id));
+ },
+ prices: { EUR: 450, PLN: 1900, USD: 500 },
+ returnPath: "/example/payments",
+ });
+
+ const plan = definePaymentOffer({
+ id: "plan",
+ intervals: {
+ month: { PLN: 1900, USD: 500 },
+ year: { PLN: 19000, USD: 5000 },
+ },
+ mode: "subscription",
+ name: "Supporter plan",
+ onSubscriptionChanged: async ({ subscription, tx }) => {
+ handlerCalls.push({
+ kind: "subscription",
+ purchaseId: subscription.purchaseId,
+ });
+ failIfAsked();
+ const accessUntil =
+ subscription.endedAt &&
+ subscription.paidThrough &&
+ subscription.endedAt < subscription.paidThrough
+ ? subscription.endedAt
+ : subscription.paidThrough;
+ if (!accessUntil) return;
+ await tx
+ .insert(test_payments_access)
+ .values({
+ accessUntil,
+ offerId: subscription.offerId,
+ purchaseId: subscription.purchaseId,
+ userId: must(subscription.userId),
+ })
+ .onConflictDoUpdate({
+ set: { accessUntil, purchaseId: subscription.purchaseId },
+ target: [test_payments_access.userId, test_payments_access.offerId],
+ });
+ },
+ returnPath: "/example/payments",
+ });
+
+ const registry = createPaymentsRegistry({
+ config: payments
+ ? {
+ currencies: { EUR: {}, PLN: { currencyDisplay: "code" }, USD: {} },
+ defaultCurrency: "PLN",
+ providers: [fake.provider],
+ ...config,
+ }
+ : undefined,
+ offers: [lifetime, plan, ...extraOffers].map(offer => ({
+ offer,
+ pluginId: PLUGIN_ID,
+ })),
+ });
+
+ const queueTasks = [processPaymentEventTask, fulfillPaymentTask].map(
+ task => ({
+ ...task,
+ module: "payments",
+ pluginId: "@vitnode/core",
+ }),
+ );
+ const translator = createTranslator({ locale: "en", messages: {} });
+
+ const makeContext = (db: TestDatabaseHandle["db"]): Context => {
+ const vars = new Map();
+ const c = {
+ get: (key: string) => vars.get(key),
+ set: (key: string, value: unknown) => vars.set(key, value),
+ } as unknown as Context;
+
+ vars.set("db", db);
+ vars.set("cache", createTestCache());
+ vars.set("user", null);
+ vars.set("admin", null);
+ vars.set("core", {
+ hasCronAdapter: false,
+ payments: registry,
+ queue: queueTasks,
+ });
+ vars.set("i18n", {
+ getTranslator: async () => await Promise.resolve(translator),
+ });
+ vars.set("log", {
+ debug: async () => {},
+ error: async () => {},
+ info: async () => {},
+ warn: async () => {},
+ });
+ vars.set("events", {
+ emit: async (name: string, payload: unknown) => {
+ emitted.push({ name, payload });
+
+ return await Promise.resolve({
+ delivered: 0,
+ failures: [],
+ status: "delivered",
+ });
+ },
+ });
+ vars.set("queue", new QueueModel(c));
+
+ return c;
+ };
+
+ const c = makeContext(database.db);
+ let userSeq = 0;
+
+ const middleware: PaymentsHarness["middleware"] =
+ ({ admin, queue, user } = {}) =>
+ async (target, next) => {
+ for (const key of [
+ "db",
+ "cache",
+ "admin",
+ "core",
+ "i18n",
+ "log",
+ "events",
+ ]) {
+ target.set(key as never, c.get(key as never));
+ }
+ target.set("user" as never, (user?.() ?? null) as never);
+ const adminUser = admin?.() ?? null;
+ target.set(
+ "admin" as never,
+ (adminUser ? { user: adminUser } : null) as never,
+ );
+ target.set(
+ "queue" as never,
+ (queue?.() ?? new QueueModel(target)) as never,
+ );
+ await next();
+ };
+
+ return {
+ middleware,
+ access: async userId =>
+ await database.db
+ .select()
+ .from(test_payments_access)
+ .where(eq(test_payments_access.userId, userId)),
+ c,
+ createUser: async () => {
+ userSeq += 1;
+ const [row] = await database.db
+ .insert(core_users)
+ .values({
+ avatarColor: "000000",
+ email: `buyer${userSeq}@example.com`,
+ ipAddress: "127.0.0.1",
+ language: "en",
+ name: `Buyer ${userSeq}`,
+ nameCode: `buyer-${userSeq}`,
+ roleId: 1,
+ })
+ .returning();
+
+ return {
+ email: row.email,
+ id: row.id,
+ language: row.language,
+ name: row.name,
+ };
+ },
+ database,
+ drainQueue: async (now = new Date()) => {
+ let runs = 0;
+ for (let round = 0; round < 50; round++) {
+ const due = await database.db
+ .select({ id: core_queue.id })
+ .from(core_queue)
+ .where(
+ and(
+ eq(core_queue.status, "pending"),
+ lte(core_queue.availableAt, now),
+ ),
+ );
+ if (due.length === 0) break;
+ runs += due.length;
+ await processQueueTasksByIds(
+ c,
+ due.map(row => row.id),
+ );
+ }
+
+ return runs;
+ },
+ emitted,
+ fake,
+ forkContext: () => makeContext(database.connect()),
+ handlerCalls,
+ setHandlerFailure: error => {
+ handlerFailure = error;
+ },
+ };
+};
+
+export const TEST_PLUGIN_ID = PLUGIN_ID;
diff --git a/packages/vitnode/src/tests/postgres.ts b/packages/vitnode/src/tests/postgres.ts
new file mode 100644
index 0000000000..710a19ee5c
--- /dev/null
+++ b/packages/vitnode/src/tests/postgres.ts
@@ -0,0 +1,91 @@
+import {
+ generateDrizzleJson,
+ generateMigration,
+} from "drizzle-kit/api-postgres";
+import { drizzle } from "drizzle-orm/postgres-js";
+import postgres from "postgres";
+import { describe } from "vitest";
+
+import type { EnvVariablesVitNode } from "@/api/middlewares/global.middleware";
+
+import { coreRelations, coreSchema } from "@/database/relations";
+
+const TEST_POSTGRES_URL = process.env.VITNODE_TEST_POSTGRES_URL;
+
+export const describePostgres = (name: string, body: () => void): void => {
+ describe.skipIf(!TEST_POSTGRES_URL)(name, body);
+};
+
+type TestDatabase = EnvVariablesVitNode["db"];
+
+export interface TestDatabaseHandle {
+ connect: () => TestDatabase;
+ db: TestDatabase;
+ drop: () => Promise;
+}
+
+const withDatabase = (url: string, database: string): string => {
+ const parsed = new URL(url);
+ parsed.pathname = `/${database}`;
+
+ return parsed.toString();
+};
+
+export const createTestDatabase = async (
+ schema: Record = coreSchema,
+ { onQuery }: { onQuery?: () => void } = {},
+): Promise => {
+ if (!TEST_POSTGRES_URL) {
+ throw new Error("VITNODE_TEST_POSTGRES_URL is not set.");
+ }
+
+ const name = `vitnode_test_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
+ const admin = postgres(TEST_POSTGRES_URL, { max: 1, onnotice: () => {} });
+ await admin.unsafe(`CREATE DATABASE "${name}"`);
+ await admin.end();
+
+ const url = withDatabase(TEST_POSTGRES_URL, name);
+ const statements = await generateMigration(
+ await generateDrizzleJson({}),
+ await generateDrizzleJson(schema),
+ );
+
+ const setup = postgres(url, { max: 1, onnotice: () => {} });
+ await setup.unsafe("SET TIME ZONE 'UTC'");
+ for (const statement of statements) {
+ await setup.unsafe(statement);
+ }
+ await setup.end();
+
+ const clients: ReturnType[] = [];
+ const connect = (): TestDatabase => {
+ const client = postgres(url, {
+ connection: { TimeZone: "UTC" },
+ max: 20,
+ onnotice: () => {},
+ });
+ clients.push(client);
+
+ return drizzle({
+ client,
+ logger: onQuery ? { logQuery: () => onQuery() } : undefined,
+ relations: coreRelations,
+ });
+ };
+
+ return {
+ connect,
+ db: connect(),
+ drop: async () => {
+ await Promise.all(
+ clients.map(async client => await client.end({ timeout: 5 })),
+ );
+ const cleanup = postgres(TEST_POSTGRES_URL, {
+ max: 1,
+ onnotice: () => {},
+ });
+ await cleanup.unsafe(`DROP DATABASE IF EXISTS "${name}" WITH (FORCE)`);
+ await cleanup.end({ timeout: 5 });
+ },
+ };
+};
diff --git a/packages/vitnode/src/views/admin/layouts/sidebar/nav/nav-model.test.ts b/packages/vitnode/src/views/admin/layouts/sidebar/nav/nav-model.test.ts
index 69cef7a1b8..1bdc3ce90e 100644
--- a/packages/vitnode/src/views/admin/layouts/sidebar/nav/nav-model.test.ts
+++ b/packages/vitnode/src/views/admin/layouts/sidebar/nav/nav-model.test.ts
@@ -51,6 +51,7 @@ const hrefs = (nav: ReturnType): string[] =>
describe("the core group", () => {
it("gives a root admin every core screen", () => {
const nav = buildAdminNav({
+ features: { payments: true },
permissions: root,
t,
vitNodeConfig: config(),
@@ -74,6 +75,10 @@ describe("the core group", () => {
"/admin/core/ai",
"/admin/core/ai/actions",
"/admin/core/ai/history",
+ "/admin/core/payments",
+ "/admin/core/payments",
+ "/admin/core/payments/subscriptions",
+ "/admin/core/payments/events",
"/admin/core/advanced",
"/admin/core/advanced/search",
"/admin/core/advanced/cron",
diff --git a/packages/vitnode/src/views/admin/layouts/sidebar/nav/nav-model.tsx b/packages/vitnode/src/views/admin/layouts/sidebar/nav/nav-model.tsx
index 7d0190abd8..7980b2f812 100644
--- a/packages/vitnode/src/views/admin/layouts/sidebar/nav/nav-model.tsx
+++ b/packages/vitnode/src/views/admin/layouts/sidebar/nav/nav-model.tsx
@@ -1,4 +1,5 @@
import {
+ CreditCardIcon,
FileTextIcon,
LayoutDashboardIcon,
ServerIcon,
@@ -63,8 +64,16 @@ export interface AdminNavSubItemDeclaration {
title: AdminNavTitle;
}
+/** Optional features an install may leave unconfigured. */
+export type AdminNavFeature = "payments";
+
+/** Which optional features this install has configured. */
+export type AdminNavFeatures = Partial>;
+
/** A top-level declaration, which may carry an icon and sub-items. */
export interface AdminNavItemDeclaration extends AdminNavSubItemDeclaration {
+ /** Shown only while this feature is configured. */
+ feature?: AdminNavFeature;
icon?: React.ReactNode;
items?: AdminNavSubItemDeclaration[];
}
@@ -96,10 +105,12 @@ export const filterNavItems = (
items: AdminNavItemDeclaration[],
set: StaffPermissionSet,
t: AdminNavTranslator,
+ features: AdminNavFeatures = {},
): AdminNavItem[] => {
const result: AdminNavItem[] = [];
for (const item of items) {
+ if (item.feature && !features[item.feature]) continue;
if (!isAllowed(item.permission, set)) continue;
if (item.items && item.items.length > 0) {
@@ -241,6 +252,29 @@ const coreNavGroup = (): AdminNavGroupDeclaration => ({
},
],
},
+ {
+ href: "/admin/core/payments",
+ title: key("admin.global.nav.payments.title"),
+ icon: ,
+ feature: "payments",
+ items: [
+ {
+ title: key("admin.global.nav.payments.overview"),
+ href: "/admin/core/payments",
+ permission: core("payments"),
+ },
+ {
+ title: key("admin.global.nav.payments.subscriptions"),
+ href: "/admin/core/payments/subscriptions",
+ permission: core("payments"),
+ },
+ {
+ title: key("admin.global.nav.payments.events"),
+ href: "/admin/core/payments/events",
+ permission: core("payments"),
+ },
+ ],
+ },
{
href: "/admin/core/advanced",
title: key("admin.global.nav.advanced.title"),
@@ -439,17 +473,19 @@ export const adminNavBundle = (
*/
export const resolveAdminNav = ({
declarations,
+ features,
permissions,
t,
}: {
declarations: AdminNavGroupDeclaration[];
+ features?: AdminNavFeatures;
permissions: StaffPermissionSet;
t: AdminNavTranslator;
}): NavAdminParent[] =>
declarations
.map(group => ({
id: group.id,
- items: filterNavItems(group.items, permissions, t),
+ items: filterNavItems(group.items, permissions, t, features),
title: resolveTitle(group.title, t),
}))
.filter(group => group.items.length > 0);
@@ -463,16 +499,19 @@ export const resolveAdminNav = ({
* separately instead.
*/
export const buildAdminNav = ({
+ features,
permissions,
t,
vitNodeConfig,
}: {
+ features?: AdminNavFeatures;
permissions: StaffPermissionSet;
t: AdminNavTranslator;
vitNodeConfig: VitNodeConfig;
}): NavAdminParent[] =>
resolveAdminNav({
declarations: adminNavDeclarations(vitNodeConfig),
+ features,
permissions,
t,
});
diff --git a/packages/vitnode/src/views/admin/views/core/payments/events-table-content.tsx b/packages/vitnode/src/views/admin/views/core/payments/events-table-content.tsx
new file mode 100644
index 0000000000..b4b809b828
--- /dev/null
+++ b/packages/vitnode/src/views/admin/views/core/payments/events-table-content.tsx
@@ -0,0 +1,138 @@
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import { RotateCwIcon } from "lucide-react";
+import { toast } from "sonner";
+import { useTranslations } from "use-intl";
+
+import type { AdminTablePage } from "@/views/admin/table/params";
+
+import { DateFormat } from "@/components/date-format";
+import { ContentDataTable } from "@/components/table/content";
+import { Button } from "@/components/ui/button";
+
+import type { AdminWebhookEventRow } from "./payments-admin-query";
+
+import {
+ EVENTS_TABLE_CONTRACT,
+ paymentsAdminRoot,
+ retryWebhookEventRequest,
+} from "./payments-admin-query";
+import { WorkStatusBadge } from "./status-badges";
+
+const RetryEventButton = ({ event }: { event: AdminWebhookEventRow }) => {
+ const t = useTranslations("admin.payments.events");
+ const queryClient = useQueryClient();
+ const retry = useMutation({
+ mutationFn: retryWebhookEventRequest,
+ onError: error => {
+ toast.error(t("retry_error"), { description: error.message });
+ },
+ onSuccess: async () => {
+ toast.success(t("retried"), { description: t("retried_desc") });
+ await queryClient.invalidateQueries({ queryKey: paymentsAdminRoot });
+ },
+ });
+
+ return (
+
+ );
+};
+
+export const EventsTableContent = ({
+ canRetry,
+ data,
+}: {
+ canRetry: boolean;
+ data: AdminTablePage;
+}) => {
+ const t = useTranslations("admin.payments");
+ const tStatus = useTranslations("admin.payments.event_status");
+
+ return (
+
+ columns={[
+ {
+ accessorKey: "type",
+ header: t("events.type"),
+ cell: ({ row }) => (
+
+ {row.type}
+
+ {row.provider} ({row.providerScope}) · {row.externalId}
+
+
+ ),
+ },
+ {
+ accessorKey: "targetId",
+ header: t("events.target"),
+ cell: ({ row }) => (
+
+ {row.targetKind}: {row.targetId}
+
+ ),
+ },
+ {
+ accessorKey: "status",
+ header: t("events.status"),
+ cell: ({ row }) => (
+
+ ),
+ },
+ { accessorKey: "attempts", header: t("events.attempts") },
+ {
+ accessorKey: "receivedAt",
+ header: t("events.received"),
+ cell: ({ row }) => ,
+ },
+ {
+ accessorKey: "lastError",
+ header: t("events.error"),
+ cell: ({ row }) =>
+ row.lastError ? (
+
+
+ {row.lastError}
+
+ {canRetry && row.status === "failed" ? (
+
+ ) : null}
+
+ ) : (
+ —
+ ),
+ },
+ ]}
+ edges={data.edges}
+ filters={[
+ {
+ id: "status",
+ label: t("filters.status"),
+ options: (EVENTS_TABLE_CONTRACT.status ?? []).map(status => ({
+ label: tStatus(status as "failed" | "pending" | "processed"),
+ value: status,
+ })),
+ },
+ ]}
+ id="payments-events-table"
+ order={{
+ columns: ["receivedAt"],
+ defaultOrder: { column: "receivedAt", order: "desc" },
+ }}
+ pageInfo={data.pageInfo}
+ />
+ );
+};
diff --git a/packages/vitnode/src/views/admin/views/core/payments/filters.ts b/packages/vitnode/src/views/admin/views/core/payments/filters.ts
new file mode 100644
index 0000000000..e69c68d62c
--- /dev/null
+++ b/packages/vitnode/src/views/admin/views/core/payments/filters.ts
@@ -0,0 +1,37 @@
+import { useTranslations } from "use-intl";
+
+import type { FilterDataTable } from "@/components/table/filters";
+
+import { PAYMENT_PERIOD_FILTERS } from "./payments-admin-query";
+
+/** Currency, provider and period - the filters every payments list shares. */
+export const usePaymentFilters = ({
+ currencies,
+ providers,
+}: {
+ currencies: string[];
+ providers: string[];
+}): FilterDataTable[] => {
+ const t = useTranslations("admin.payments.filters");
+
+ return [
+ {
+ id: "currency",
+ label: t("currency"),
+ options: currencies.map(code => ({ label: code, value: code })),
+ },
+ {
+ id: "provider",
+ label: t("provider"),
+ options: providers.map(id => ({ label: id, value: id })),
+ },
+ {
+ id: "period",
+ label: t("period"),
+ options: PAYMENT_PERIOD_FILTERS.map(period => ({
+ label: t(`periods.${period}`),
+ value: period,
+ })),
+ },
+ ];
+};
diff --git a/packages/vitnode/src/views/admin/views/core/payments/overview-content.tsx b/packages/vitnode/src/views/admin/views/core/payments/overview-content.tsx
new file mode 100644
index 0000000000..82a1b0b68a
--- /dev/null
+++ b/packages/vitnode/src/views/admin/views/core/payments/overview-content.tsx
@@ -0,0 +1,166 @@
+import { AlertTriangleIcon, CheckCircle2Icon } from "lucide-react";
+import { useFormatter, useTranslations } from "use-intl";
+
+import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
+import { Badge } from "@/components/ui/badge";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
+import { useMoneyFormatter } from "@/views/payments/money-text";
+
+import type { PaymentsOverview } from "./payments-admin-query";
+
+const WorkerStatus = ({ worker }: { worker: PaymentsOverview["worker"] }) => {
+ const t = useTranslations("admin.payments.overview");
+ const format = useFormatter();
+ const healthy =
+ !worker.stale && worker.queueLagging === 0 && !!worker.lastRun;
+ const lastRun = worker.lastRun
+ ? t("last_run", {
+ date: format.dateTime(new Date(worker.lastRun), {
+ dateStyle: "medium",
+ timeStyle: "short",
+ }),
+ })
+ : t("never_run");
+
+ return (
+ : }
+ variant={healthy ? "success" : "warning"}
+ >
+ {t("worker")}
+
+ {healthy ? t("worker_ok") : t("worker_stale")}
+ {worker.queueLagging > 0 ? (
+ {t("worker_lagging", { count: worker.queueLagging })}
+ ) : null}
+ {!worker.hasCronAdapter ? {t("worker_no_adapter")} : null}
+ {lastRun}
+
+
+ );
+};
+
+export const PaymentsOverviewContent = ({
+ data,
+}: {
+ data: PaymentsOverview;
+}) => {
+ const t = useTranslations("admin.payments");
+ const formatMoney = useMoneyFormatter();
+ const attention = [
+ ["fulfillments_failed", data.attention.fulfillmentsFailed],
+ ["events_failed", data.attention.eventsFailed],
+ ["disputes", data.attention.disputesOpen],
+ ["subscriptions_attention", data.attention.subscriptionsNeedingAttention],
+ ["processing", data.attention.purchasesProcessing],
+ ["fulfillments_pending", data.attention.fulfillmentsPending],
+ ] as const;
+
+ return (
+
+
+
+
+
+
+ {t("overview.totals")}
+ {t("overview.totals_desc")}
+
+
+ {data.totals.length === 0 ? (
+
+ {t("overview.no_totals")}
+
+ ) : (
+
+ {data.totals.map(total => (
+ -
+ {total.currency}
+
+
+
- {t("overview.one_time")}
+ -
+ {formatMoney({
+ amount: total.oneTime,
+ currency: total.currency,
+ })}
+
+
+
+
- {t("overview.recurring")}
+ -
+ {formatMoney({
+ amount: total.recurring,
+ currency: total.currency,
+ })}
+
+
+
+
- {t("overview.refunded")}
+ -
+ {formatMoney({
+ amount: total.refunded,
+ currency: total.currency,
+ })}
+
+
+
+
+ ))}
+
+ )}
+
+
+
+
+
+ {t("overview.attention")}
+
+
+
+ {attention.map(([key, value]) => (
+
+
-
+ {t(`overview.${key}`)}
+
+ -
+ 0 &&
+ key !== "processing" &&
+ key !== "fulfillments_pending"
+ ? "destructive"
+ : "secondary"
+ }
+ >
+ {value}
+
+
+
+ ))}
+
+ {data.providers.length > 0 ? (
+
+
+ {t("overview.providers")}
+
+ {data.providers.map(provider => (
+
+ {provider.name} ·{" "}
+ {t("overview.scope", { scope: provider.scope })}
+
+ ))}
+
+ ) : null}
+
+
+
+
+ );
+};
diff --git a/packages/vitnode/src/views/admin/views/core/payments/payments-admin-query.ts b/packages/vitnode/src/views/admin/views/core/payments/payments-admin-query.ts
new file mode 100644
index 0000000000..14539c7b2f
--- /dev/null
+++ b/packages/vitnode/src/views/admin/views/core/payments/payments-admin-query.ts
@@ -0,0 +1,317 @@
+import { queryOptions } from "@tanstack/react-query";
+
+import type {
+ AdminTableContract,
+ AdminTablePage,
+ AdminTableParams,
+} from "@/views/admin/table/params";
+import type { Purchase, Subscription } from "@/views/payments/payments-query";
+
+import { CONFIG_PLUGIN } from "@/config";
+import { OPERATIONAL_STALE_TIME } from "@/lib/query-freshness";
+import {
+ FULFILLMENT_STATUSES,
+ PURCHASE_PAYMENT_STATUSES,
+ SUBSCRIPTION_STATUSES,
+} from "@/payments/status";
+import { fetcher } from "@/tanstack/fetcher";
+import {
+ AdminRequestError,
+ describeAdminParams,
+} from "@/views/admin/admin-request";
+import { adminQueryRoot } from "@/views/admin/table/query";
+
+type JsonDate = Date | string;
+
+export const PAYMENT_PERIOD_FILTERS = ["7d", "30d", "90d", "365d"] as const;
+
+/** Filters payments lists add on top of the shared admin table contract. */
+export interface PaymentsListFilters {
+ currency?: string;
+ fulfillment?: string;
+ period?: string;
+ provider?: string;
+}
+
+export const PURCHASES_TABLE_CONTRACT: AdminTableContract<
+ "amount" | "createdAt"
+> = {
+ orderBy: ["createdAt", "amount"],
+ status: PURCHASE_PAYMENT_STATUSES,
+};
+
+export const SUBSCRIPTIONS_TABLE_CONTRACT: AdminTableContract<
+ "createdAt" | "paidThrough"
+> = {
+ orderBy: ["createdAt", "paidThrough"],
+ status: SUBSCRIPTION_STATUSES,
+};
+
+export const EVENTS_TABLE_CONTRACT: AdminTableContract<"receivedAt"> = {
+ orderBy: ["receivedAt"],
+ status: ["pending", "processed", "failed"],
+};
+
+export { FULFILLMENT_STATUSES };
+
+export type PurchasesAdminParams = AdminTableParams<"amount" | "createdAt"> &
+ PaymentsListFilters;
+export type SubscriptionsAdminParams = AdminTableParams<
+ "createdAt" | "paidThrough"
+> &
+ PaymentsListFilters;
+export type EventsAdminParams = AdminTableParams<"receivedAt">;
+
+export interface AdminPurchaseRow extends Omit {
+ id: number;
+ lastError: null | string;
+ providerScope: string;
+ publicId: string;
+ user: null | { id: number; name: string };
+}
+
+export interface AdminSubscriptionRow extends Omit {
+ id: number;
+ lastError: null | string;
+ publicId: string;
+ purchaseId: string;
+ user: null | { id: number; name: string };
+}
+
+export interface AdminWebhookEventRow {
+ attempts: number;
+ externalId: string;
+ id: number;
+ lastError: null | string;
+ processedAt: JsonDate | null;
+ provider: string;
+ providerScope: string;
+ receivedAt: JsonDate;
+ status: "failed" | "pending" | "processed";
+ targetId: string;
+ targetKind: string;
+ type: string;
+}
+
+export interface PaymentsOverview {
+ attention: {
+ disputesOpen: number;
+ eventsFailed: number;
+ fulfillmentsFailed: number;
+ fulfillmentsPending: number;
+ purchasesProcessing: number;
+ subscriptionsNeedingAttention: number;
+ };
+ enabled: boolean;
+ providers: { id: string; name: string; scope: string }[];
+ totals: {
+ currency: string;
+ oneTime: number;
+ recurring: number;
+ refunded: number;
+ }[];
+ worker: {
+ hasCronAdapter: boolean;
+ lastRun: JsonDate | null;
+ queueLagging: number;
+ stale: boolean;
+ };
+}
+
+export interface AdminPurchaseDetail {
+ adjustments: {
+ amount: number;
+ createdAt: JsonDate;
+ currency: string;
+ externalId: string;
+ invoiceId: null | number;
+ kind: "dispute" | "refund";
+ reason: null | string;
+ status: string;
+ }[];
+ checkouts: {
+ createdAt: JsonDate;
+ expiresAt: JsonDate;
+ externalId: null | string;
+ lastError: null | string;
+ status: string;
+ }[];
+ externalCustomerId: null | string;
+ externalPaymentId: null | string;
+ fulfillments: {
+ attempts: number;
+ completedAt: JsonDate | null;
+ effect: string;
+ id: number;
+ lastError: null | string;
+ retryable: boolean;
+ status: string;
+ updatedAt: JsonDate;
+ }[];
+ invoices: {
+ amountDue: number;
+ amountPaid: number;
+ billingReason: null | string;
+ createdAt: JsonDate;
+ currency: string;
+ disputeStatus: null | string;
+ externalId: string;
+ id: number;
+ paidAt: JsonDate | null;
+ periodEnd: JsonDate | null;
+ periodStart: JsonDate | null;
+ refundedAmount: number;
+ status: string;
+ }[];
+ offerRegistered: boolean;
+ purchase: AdminPurchaseRow;
+ subscription:
+ | null
+ | (Subscription & {
+ externalId: string;
+ lastError: null | string;
+ providerStatus: string;
+ syncedAt: JsonDate;
+ });
+}
+
+const failed = (status: number, screen: string, params?: object) =>
+ new AdminRequestError(
+ status,
+ screen,
+ params ? describeAdminParams(params) : undefined,
+ );
+
+export const paymentsAdminRoot = adminQueryRoot("payments");
+
+export const paymentsOverviewQueryOptions = () =>
+ queryOptions({
+ queryFn: async (): Promise => {
+ const response = await fetcher({
+ plugin: CONFIG_PLUGIN.pluginId,
+ method: "get",
+ module: "admin/payments",
+ path: "/overview",
+ });
+ if (!response.ok) throw failed(response.status, "the payments overview");
+
+ return await response.json();
+ },
+ queryKey: [...paymentsAdminRoot, "overview"] as const,
+ retry: false,
+ staleTime: OPERATIONAL_STALE_TIME,
+ });
+
+export const adminPurchasesQueryOptions = ({
+ params,
+}: {
+ params: PurchasesAdminParams;
+}) =>
+ queryOptions({
+ queryFn: async (): Promise> => {
+ const response = await fetcher({
+ plugin: CONFIG_PLUGIN.pluginId,
+ args: { query: params },
+ method: "get",
+ module: "admin/payments",
+ path: "/purchases",
+ });
+ if (!response.ok)
+ throw failed(response.status, "the purchases list", params);
+
+ return await response.json();
+ },
+ queryKey: [...paymentsAdminRoot, "purchases", params] as const,
+ retry: false,
+ staleTime: OPERATIONAL_STALE_TIME,
+ });
+
+export const adminSubscriptionsQueryOptions = ({
+ params,
+}: {
+ params: SubscriptionsAdminParams;
+}) =>
+ queryOptions({
+ queryFn: async (): Promise> => {
+ const response = await fetcher({
+ plugin: CONFIG_PLUGIN.pluginId,
+ args: { query: params },
+ method: "get",
+ module: "admin/payments",
+ path: "/subscriptions",
+ });
+ if (!response.ok) {
+ throw failed(response.status, "the subscriptions list", params);
+ }
+
+ return await response.json();
+ },
+ queryKey: [...paymentsAdminRoot, "subscriptions", params] as const,
+ retry: false,
+ staleTime: OPERATIONAL_STALE_TIME,
+ });
+
+export const adminWebhookEventsQueryOptions = ({
+ params,
+}: {
+ params: EventsAdminParams;
+}) =>
+ queryOptions({
+ queryFn: async (): Promise> => {
+ const response = await fetcher({
+ plugin: CONFIG_PLUGIN.pluginId,
+ args: { query: params },
+ method: "get",
+ module: "admin/payments",
+ path: "/events",
+ });
+ if (!response.ok)
+ throw failed(response.status, "the webhook events", params);
+
+ return await response.json();
+ },
+ queryKey: [...paymentsAdminRoot, "events", params] as const,
+ retry: false,
+ staleTime: OPERATIONAL_STALE_TIME,
+ });
+
+export const adminPurchaseQueryOptions = ({ id }: { id: string }) =>
+ queryOptions({
+ queryFn: async (): Promise => {
+ const response = await fetcher({
+ plugin: CONFIG_PLUGIN.pluginId,
+ args: { params: { id } },
+ method: "get",
+ module: "admin/payments",
+ path: "/purchases/{id}",
+ });
+ if (!response.ok) throw failed(response.status, "the purchase");
+
+ return await response.json();
+ },
+ queryKey: [...paymentsAdminRoot, "purchase", id] as const,
+ retry: false,
+ staleTime: OPERATIONAL_STALE_TIME,
+ });
+
+export const retryFulfillmentRequest = async (id: number): Promise => {
+ const response = await fetcher({
+ plugin: CONFIG_PLUGIN.pluginId,
+ args: { params: { id } },
+ method: "post",
+ module: "admin/payments",
+ path: "/fulfillments/{id}/retry",
+ });
+ if (!response.ok) throw failed(response.status, "the fulfillment retry");
+};
+
+export const retryWebhookEventRequest = async (id: number): Promise => {
+ const response = await fetcher({
+ plugin: CONFIG_PLUGIN.pluginId,
+ args: { params: { id } },
+ method: "post",
+ module: "admin/payments",
+ path: "/events/{id}/retry",
+ });
+ if (!response.ok) throw failed(response.status, "the webhook event retry");
+};
diff --git a/packages/vitnode/src/views/admin/views/core/payments/purchase-detail-content.tsx b/packages/vitnode/src/views/admin/views/core/payments/purchase-detail-content.tsx
new file mode 100644
index 0000000000..12369feb65
--- /dev/null
+++ b/packages/vitnode/src/views/admin/views/core/payments/purchase-detail-content.tsx
@@ -0,0 +1,386 @@
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import { Link } from "@tanstack/react-router";
+import { ArrowLeftIcon, RotateCwIcon } from "lucide-react";
+import { toast } from "sonner";
+import { useFormatter, useTranslations } from "use-intl";
+
+import { Alert, AlertDescription } from "@/components/ui/alert";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table";
+import { useMoneyFormatter } from "@/views/payments/money-text";
+import { PurchaseStateBadge } from "@/views/payments/state-badges";
+
+import type { AdminPurchaseDetail } from "./payments-admin-query";
+
+import {
+ paymentsAdminRoot,
+ retryFulfillmentRequest,
+} from "./payments-admin-query";
+import {
+ AdminSubscriptionStatusBadge,
+ FulfillmentStatusBadge,
+ PaymentStatusBadge,
+ WorkStatusBadge,
+} from "./status-badges";
+
+type JsonDate = Date | null | string;
+
+const useDate = () => {
+ const format = useFormatter();
+
+ return (value: JsonDate) =>
+ value
+ ? format.dateTime(new Date(value), {
+ dateStyle: "medium",
+ timeStyle: "short",
+ })
+ : "—";
+};
+
+const Field = ({
+ label,
+ children,
+}: {
+ children: React.ReactNode;
+ label: string;
+}) => (
+
+
{label}
+ {children}
+
+);
+
+const RetryFulfillmentButton = ({ id }: { id: number }) => {
+ const t = useTranslations("admin.payments.detail");
+ const queryClient = useQueryClient();
+ const retry = useMutation({
+ mutationFn: retryFulfillmentRequest,
+ onError: error => {
+ toast.error(t("retry_error"), { description: error.message });
+ },
+ onSuccess: async () => {
+ toast.success(t("retried"), { description: t("retried_desc") });
+ await queryClient.invalidateQueries({ queryKey: paymentsAdminRoot });
+ },
+ });
+
+ return (
+
+ );
+};
+
+export const PurchaseDetailContent = ({
+ canRetry,
+ data,
+}: {
+ canRetry: boolean;
+ data: AdminPurchaseDetail;
+}) => {
+ const t = useTranslations("admin.payments");
+ const tCore = useTranslations("core.payments");
+ const date = useDate();
+ const formatMoney = useMoneyFormatter();
+ const { purchase, subscription } = data;
+ const money = (amount: number, currency = purchase.currency) =>
+ formatMoney({ amount, currency });
+
+ return (
+
+
}
+ size="sm"
+ variant="ghost"
+ >
+
+ {t("detail.back")}
+
+
+ {data.offerRegistered ? null : (
+
+ {t("detail.offer_missing")}
+
+ )}
+
+ {purchase.lastError ? (
+
+
+ {t("detail.last_error")}: {purchase.lastError}
+
+
+ ) : null}
+
+
+
+
+ {purchase.offerName}
+
+
+
+
+
+
+ {money(purchase.amount)}
+ {purchase.interval
+ ? ` · ${tCore(`interval.${purchase.interval}`)}`
+ : null}
+
+
+
+
+
+
+
+
+
+ {money(purchase.refundedAmount)} ·{" "}
+ {t(`refund_status.${purchase.refundStatus}`)}
+
+
+
+ {purchase.user?.name ?? t("purchases.deleted_user")}
+
+
+ {date(purchase.createdAt)}
+
+
+ {purchase.provider} ({purchase.providerScope})
+
+
+ {[data.externalPaymentId, data.externalCustomerId]
+ .filter(Boolean)
+ .join(" · ") || t("detail.none")}
+
+ {purchase.offerId}
+
+
+
+
+
+
+ {t("detail.fulfillments")}
+
+
+ {data.fulfillments.length === 0 ? (
+ {t("detail.none")}
+ ) : (
+
+
+
+
+ {t("detail.effect")}
+ {t("events.status")}
+ {t("detail.attempts")}
+ {t("detail.last_error")}
+
+ {t("detail.retry")}
+
+
+
+
+ {data.fulfillments.map(fulfillment => (
+
+
+ {fulfillment.effect}
+
+
+
+
+
+ {fulfillment.attempts}
+
+
+
+ {fulfillment.lastError ?? "—"}
+
+
+
+ {canRetry &&
+ fulfillment.retryable &&
+ fulfillment.status === "failed" ? (
+
+ ) : null}
+
+
+ ))}
+
+
+
+ )}
+
+
+
+ {subscription ? (
+
+
+
+ {t("detail.subscription")}
+
+
+
+
+
+
+
+ {tCore(`per_interval.${subscription.interval}`, {
+ price: money(subscription.amount, subscription.currency),
+ })}
+
+
+
+ {date(subscription.paidThrough)}
+
+
+ {subscription.cancelAtPeriodEnd
+ ? date(subscription.cancelAt)
+ : "—"}
+
+
+ {subscription.externalId} · {subscription.providerStatus}
+
+
+ {date(subscription.syncedAt)}
+
+ {subscription.lastError ? (
+
+ {subscription.lastError}
+
+ ) : null}
+
+
+
+ ) : null}
+
+ {data.invoices.length > 0 ? (
+
+
+ {t("detail.invoices")}
+
+
+
+
+
+ {t("detail.period")}
+ {t("purchases.amount")}
+ {t("purchases.status")}
+ {t("overview.refunded")}
+
+
+
+ {data.invoices.map(invoice => (
+
+
+ {date(invoice.periodStart)} – {date(invoice.periodEnd)}
+
+
+ {money(
+ invoice.amountPaid || invoice.amountDue,
+ invoice.currency,
+ )}
+
+
+ {invoice.status}
+ {invoice.disputeStatus
+ ? ` · ${t("detail.dispute")}`
+ : null}
+
+
+ {money(invoice.refundedAmount, invoice.currency)}
+
+
+ ))}
+
+
+
+
+ ) : null}
+
+
+
+
+ {t("detail.adjustments")}
+
+
+ {data.adjustments.length === 0 ? (
+
+ {t("detail.none")}
+
+ ) : (
+
+ {data.adjustments.map(adjustment => (
+ -
+
+ {t(`detail.${adjustment.kind}`)} ·{" "}
+
{adjustment.status}
+ {adjustment.reason ? ` · ${adjustment.reason}` : null}
+
+
+ {money(adjustment.amount, adjustment.currency)}
+
+
+ ))}
+
+ )}
+
+
+
+
+
+ {t("detail.checkouts")}
+
+
+
+ {data.checkouts.map(checkout => (
+ -
+
+
{checkout.status}
+
+ {date(checkout.createdAt)}
+
+
+ {checkout.externalId ? (
+
+ {checkout.externalId}
+
+ ) : null}
+ {checkout.lastError ? (
+
+ {checkout.lastError}
+
+ ) : null}
+
+ ))}
+
+
+
+
+
+ );
+};
diff --git a/packages/vitnode/src/views/admin/views/core/payments/purchases-table-content.tsx b/packages/vitnode/src/views/admin/views/core/payments/purchases-table-content.tsx
new file mode 100644
index 0000000000..acdd82a98c
--- /dev/null
+++ b/packages/vitnode/src/views/admin/views/core/payments/purchases-table-content.tsx
@@ -0,0 +1,128 @@
+import { Link } from "@tanstack/react-router";
+import { useTranslations } from "use-intl";
+
+import type { AdminTablePage } from "@/views/admin/table/params";
+
+import { DateFormat } from "@/components/date-format";
+import { ContentDataTable } from "@/components/table/content";
+import { PURCHASE_PAYMENT_STATUSES } from "@/payments/status";
+import { MoneyText } from "@/views/payments/money-text";
+
+import type { AdminPurchaseRow } from "./payments-admin-query";
+
+import { usePaymentFilters } from "./filters";
+import { FULFILLMENT_STATUSES } from "./payments-admin-query";
+import { FulfillmentStatusBadge, PaymentStatusBadge } from "./status-badges";
+
+export const PurchasesTableContent = ({
+ currencies,
+ data,
+ providers,
+}: {
+ currencies: string[];
+ data: AdminTablePage;
+ providers: string[];
+}) => {
+ const t = useTranslations("admin.payments");
+ const tStatus = useTranslations("admin.payments.payment_status");
+ const tFulfillment = useTranslations("admin.payments.fulfillment_status");
+ const common = usePaymentFilters({ currencies, providers });
+
+ return (
+
+ columns={[
+ {
+ accessorKey: "offerName",
+ header: t("purchases.offer"),
+ cell: ({ row }) => (
+
+
+ {row.offerName}
+
+
+ {row.pluginId}
+
+
+ ),
+ },
+ {
+ accessorKey: "user",
+ header: t("purchases.buyer"),
+ cell: ({ row }) =>
+ row.user ? (
+
+ {row.user.name}
+
+ ) : (
+
+ {t("purchases.deleted_user")}
+
+ ),
+ },
+ {
+ accessorKey: "amount",
+ header: t("purchases.amount"),
+ cell: ({ row }) => (
+
+ ),
+ },
+ {
+ accessorKey: "paymentStatus",
+ header: t("purchases.status"),
+ cell: ({ row }) => ,
+ },
+ {
+ accessorKey: "fulfillmentStatus",
+ header: t("purchases.fulfillment"),
+ cell: ({ row }) => (
+
+ ),
+ },
+ {
+ accessorKey: "provider",
+ header: t("purchases.provider"),
+ cell: ({ row }) => `${row.provider} (${row.providerScope})`,
+ },
+ {
+ accessorKey: "createdAt",
+ header: t("purchases.created"),
+ cell: ({ row }) => ,
+ },
+ ]}
+ edges={data.edges}
+ filters={[
+ {
+ id: "status",
+ label: t("filters.status"),
+ options: PURCHASE_PAYMENT_STATUSES.map(status => ({
+ label: tStatus(status),
+ value: status,
+ })),
+ },
+ {
+ id: "fulfillment",
+ label: t("filters.fulfillment"),
+ options: FULFILLMENT_STATUSES.map(status => ({
+ label: tFulfillment(status),
+ value: status,
+ })),
+ },
+ ...common,
+ ]}
+ id="payments-purchases-table"
+ order={{
+ columns: ["createdAt", "amount"],
+ defaultOrder: { column: "createdAt", order: "desc" },
+ }}
+ pageInfo={data.pageInfo}
+ />
+ );
+};
diff --git a/packages/vitnode/src/views/admin/views/core/payments/status-badges.tsx b/packages/vitnode/src/views/admin/views/core/payments/status-badges.tsx
new file mode 100644
index 0000000000..64208473e3
--- /dev/null
+++ b/packages/vitnode/src/views/admin/views/core/payments/status-badges.tsx
@@ -0,0 +1,92 @@
+import { useTranslations } from "use-intl";
+
+import type {
+ FulfillmentStatus,
+ PurchasePaymentStatus,
+ SubscriptionStatus,
+} from "@/payments/status";
+
+import { Badge } from "@/components/ui/badge";
+
+type Variant = "destructive" | "outline" | "secondary" | "success" | "warning";
+
+const PAYMENT: Record = {
+ awaiting_payment: "outline",
+ canceled: "secondary",
+ expired: "secondary",
+ failed: "destructive",
+ paid: "success",
+ processing: "warning",
+};
+
+const FULFILLMENT: Record = {
+ failed: "destructive",
+ fulfilled: "success",
+ not_started: "outline",
+ pending: "warning",
+};
+
+const SUBSCRIPTION: Record = {
+ active: "success",
+ canceled: "secondary",
+ incomplete: "outline",
+ incomplete_expired: "secondary",
+ past_due: "destructive",
+ paused: "warning",
+ unpaid: "destructive",
+};
+
+const WORK: Record = {
+ completed: "success",
+ failed: "destructive",
+ pending: "warning",
+ processed: "success",
+};
+
+export const PaymentStatusBadge = ({
+ status,
+}: {
+ status: PurchasePaymentStatus;
+}) => {
+ const t = useTranslations("admin.payments.payment_status");
+
+ return {t(status)};
+};
+
+export const FulfillmentStatusBadge = ({
+ status,
+}: {
+ status: FulfillmentStatus;
+}) => {
+ const t = useTranslations("admin.payments.fulfillment_status");
+
+ return {t(status)};
+};
+
+export const AdminSubscriptionStatusBadge = ({
+ status,
+}: {
+ status: SubscriptionStatus;
+}) => {
+ const t = useTranslations("admin.payments.subscription_status");
+
+ return {t(status)};
+};
+
+/** Queue-like states of webhook events and fulfillment work. */
+export const WorkStatusBadge = ({
+ kind,
+ status,
+}: {
+ kind: "event" | "work";
+ status: string;
+}) => {
+ const tEvent = useTranslations("admin.payments.event_status");
+ const tWork = useTranslations("admin.payments.work_status");
+ const label =
+ kind === "event"
+ ? tEvent(status as "failed" | "pending" | "processed")
+ : tWork(status as "completed" | "failed" | "pending");
+
+ return {label};
+};
diff --git a/packages/vitnode/src/views/admin/views/core/payments/subscriptions-table-content.tsx b/packages/vitnode/src/views/admin/views/core/payments/subscriptions-table-content.tsx
new file mode 100644
index 0000000000..2ee22cd2d3
--- /dev/null
+++ b/packages/vitnode/src/views/admin/views/core/payments/subscriptions-table-content.tsx
@@ -0,0 +1,119 @@
+import { Link } from "@tanstack/react-router";
+import { useFormatter, useTranslations } from "use-intl";
+
+import type { AdminTablePage } from "@/views/admin/table/params";
+
+import { DateFormat } from "@/components/date-format";
+import { ContentDataTable } from "@/components/table/content";
+import { SUBSCRIPTION_STATUSES } from "@/payments/status";
+import { useMoneyFormatter } from "@/views/payments/money-text";
+
+import type { AdminSubscriptionRow } from "./payments-admin-query";
+
+import { usePaymentFilters } from "./filters";
+import { AdminSubscriptionStatusBadge } from "./status-badges";
+
+export const SubscriptionsTableContent = ({
+ currencies,
+ data,
+ providers,
+}: {
+ currencies: string[];
+ data: AdminTablePage;
+ providers: string[];
+}) => {
+ const t = useTranslations("admin.payments");
+ const tStatus = useTranslations("admin.payments.subscription_status");
+ const tCore = useTranslations("core.payments");
+ const format = useFormatter();
+ const formatMoney = useMoneyFormatter();
+ const common = usePaymentFilters({ currencies, providers });
+
+ return (
+
+ columns={[
+ {
+ accessorKey: "offerName",
+ header: t("subscriptions.plan"),
+ cell: ({ row }) => (
+
+ {row.offerName}
+
+ ),
+ },
+ {
+ accessorKey: "user",
+ header: t("subscriptions.subscriber"),
+ cell: ({ row }) =>
+ row.user?.name ?? (
+
+ {t("purchases.deleted_user")}
+
+ ),
+ },
+ {
+ accessorKey: "amount",
+ header: t("subscriptions.price"),
+ cell: ({ row }) => (
+
+ {tCore(`per_interval.${row.interval}`, {
+ price: formatMoney(row),
+ })}
+
+ ),
+ },
+ {
+ accessorKey: "status",
+ header: t("subscriptions.status"),
+ cell: ({ row }) => (
+
+
+ {row.cancelAtPeriodEnd ? (
+
+ {tCore("subscription_state.cancellation_scheduled")}
+
+ ) : null}
+
+ ),
+ },
+ {
+ accessorKey: "paidThrough",
+ header: t("subscriptions.paid_through"),
+ cell: ({ row }) =>
+ row.paidThrough
+ ? format.dateTime(new Date(row.paidThrough), {
+ dateStyle: "medium",
+ })
+ : "—",
+ },
+ {
+ accessorKey: "createdAt",
+ header: t("subscriptions.created"),
+ cell: ({ row }) => ,
+ },
+ ]}
+ edges={data.edges}
+ filters={[
+ {
+ id: "status",
+ label: t("filters.status"),
+ options: SUBSCRIPTION_STATUSES.map(status => ({
+ label: tStatus(status),
+ value: status,
+ })),
+ },
+ ...common,
+ ]}
+ id="payments-subscriptions-table"
+ order={{
+ columns: ["createdAt", "paidThrough"],
+ defaultOrder: { column: "createdAt", order: "desc" },
+ }}
+ pageInfo={data.pageInfo}
+ />
+ );
+};
diff --git a/packages/vitnode/src/views/auth/settings/nav-content.tsx b/packages/vitnode/src/views/auth/settings/nav-content.tsx
index cda241474e..579ae2b5e2 100644
--- a/packages/vitnode/src/views/auth/settings/nav-content.tsx
+++ b/packages/vitnode/src/views/auth/settings/nav-content.tsx
@@ -2,6 +2,7 @@ import { Link } from "@tanstack/react-router";
import { cn } from "cn";
import {
BellIcon,
+ CreditCardIcon,
KeyRoundIcon,
LinkIcon,
MenuIcon,
@@ -31,6 +32,7 @@ const ICONS: Record<
React.ComponentType<{ className?: string }>
> = {
ai: SparklesIcon,
+ billing: CreditCardIcon,
devices: MonitorSmartphoneIcon,
notifications: BellIcon,
overview: UserRoundIcon,
diff --git a/packages/vitnode/src/views/auth/settings/settings-nav.test.ts b/packages/vitnode/src/views/auth/settings/settings-nav.test.ts
index de283924b2..40b6498b54 100644
--- a/packages/vitnode/src/views/auth/settings/settings-nav.test.ts
+++ b/packages/vitnode/src/views/auth/settings/settings-nav.test.ts
@@ -15,6 +15,19 @@ describe("visibleSettingsNavItems", () => {
).toEqual(["overview", "notifications", "devices"]);
});
+ it("shows Billing only while Payments is enabled", () => {
+ expect(
+ visibleSettingsNavItems({ passkeys: false, payments: true }).map(
+ item => item.key,
+ ),
+ ).toEqual(["overview", "notifications", "devices", "billing"]);
+ expect(
+ visibleSettingsNavItems({ passkeys: false, payments: false }).map(
+ item => item.key,
+ ),
+ ).toEqual(["overview", "notifications", "devices"]);
+ });
+
it("shows Connected accounts only while an SSO provider is configured", () => {
expect(
visibleSettingsNavItems({
diff --git a/packages/vitnode/src/views/auth/settings/settings-nav.ts b/packages/vitnode/src/views/auth/settings/settings-nav.ts
index afcb87b991..423286fe04 100644
--- a/packages/vitnode/src/views/auth/settings/settings-nav.ts
+++ b/packages/vitnode/src/views/auth/settings/settings-nav.ts
@@ -5,6 +5,7 @@ export const SETTINGS_ROOT_HREF = "/settings";
export type SettingsNavKey =
| "ai"
+ | "billing"
| "devices"
| "notifications"
| "overview"
@@ -24,24 +25,28 @@ export const SETTINGS_NAV_ITEMS: readonly SettingsNavItem[] = [
{ href: "/settings/security", key: "security" },
{ href: "/settings/sso", key: "sso" },
{ href: "/settings/ai", key: "ai" },
+ { href: "/settings/billing", key: "billing" },
];
export const visibleSettingsNavItems = ({
ai,
aiFeatures = true,
passkeys,
+ payments = false,
sso = [],
}: {
ai?: { models: readonly unknown[] };
aiFeatures?: boolean;
passkeys: boolean;
+ payments?: boolean;
sso?: readonly unknown[];
}): readonly SettingsNavItem[] =>
SETTINGS_NAV_ITEMS.filter(
item =>
(item.key !== "security" || passkeys) &&
(item.key !== "sso" || sso.length > 0) &&
- (item.key !== "ai" || ((ai?.models.length ?? 0) > 0 && aiFeatures)),
+ (item.key !== "ai" || ((ai?.models.length ?? 0) > 0 && aiFeatures)) &&
+ (item.key !== "billing" || payments),
);
/** Whether one navigation item is the panel `pathname` is showing. */
diff --git a/packages/vitnode/src/views/payments/billing/billing-content.tsx b/packages/vitnode/src/views/payments/billing/billing-content.tsx
new file mode 100644
index 0000000000..8d9c76d2f7
--- /dev/null
+++ b/packages/vitnode/src/views/payments/billing/billing-content.tsx
@@ -0,0 +1,311 @@
+import {
+ useSuspenseInfiniteQuery,
+ useSuspenseQuery,
+} from "@tanstack/react-query";
+import { CreditCardIcon, ExternalLinkIcon } from "lucide-react";
+import { toast } from "sonner";
+import { useFormatter, useTranslations } from "use-intl";
+
+import { Alert, AlertDescription } from "@/components/ui/alert";
+import { Button } from "@/components/ui/button";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
+import { PageTitle } from "@/components/ui/page-title";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table";
+
+import type { Purchase, Subscription } from "../payments-query";
+
+import { MoneyText, useMoneyFormatter } from "../money-text";
+import {
+ paymentsSettingsQueryOptions,
+ purchasesQueryOptions,
+ subscriptionsQueryOptions,
+} from "../payments-query";
+import { PurchaseStateBadge, SubscriptionStateBadge } from "../state-badges";
+import { useBillingPortal } from "../use-checkout";
+
+const toDate = (value: Date | string) =>
+ typeof value === "string" ? new Date(value) : value;
+
+const SubscriptionItem = ({
+ canManage,
+ onManage,
+ isManaging,
+ subscription,
+}: {
+ canManage: boolean;
+ isManaging: boolean;
+ onManage: () => void;
+ subscription: Subscription;
+}) => {
+ const t = useTranslations("core.payments");
+ const format = useFormatter();
+ const formatMoney = useMoneyFormatter();
+ const date = (value: Date | string) =>
+ format.dateTime(toDate(value), { dateStyle: "long" });
+ const ended = subscription.displayState === "ended";
+
+ return (
+
+
+
+
+ {subscription.offerName}
+
+
+
+ {t(`per_interval.${subscription.interval}`, {
+ price: formatMoney(subscription),
+ })}
+
+
+
+ {subscription.displayState === "needs_attention" ? (
+
+
+ {t("billing.subscriptions.attention")}
+
+
+ ) : null}
+
+ {subscription.paidThrough ? (
+
+
- {t("billing.subscriptions.title")}
+ -
+ {t("billing.subscriptions.paid_through", {
+ date: date(subscription.paidThrough),
+ })}
+
+
+ ) : null}
+ {!ended && subscription.currentPeriodEnd ? (
+
+
- {t("billing.subscriptions.title")}
+ -
+ {t(
+ subscription.cancelAtPeriodEnd
+ ? "billing.subscriptions.ends"
+ : "billing.subscriptions.renews",
+ { date: date(subscription.currentPeriodEnd) },
+ )}
+
+
+ ) : null}
+ {ended && subscription.endedAt ? (
+
+
- {t("billing.subscriptions.title")}
+ -
+ {t("billing.subscriptions.ended", {
+ date: date(subscription.endedAt),
+ })}
+
+
+ ) : null}
+
+ {canManage && !ended ? (
+
+
+
+ ) : null}
+
+
+
+ );
+};
+
+const PurchaseRow = ({ purchase }: { purchase: Purchase }) => {
+ const t = useTranslations("core.payments.billing.purchases");
+ const format = useFormatter();
+ const formatMoney = useMoneyFormatter();
+
+ return (
+
+
+ {purchase.offerName}
+
+
+
+
+
+
+
+ {purchase.refundedAmount > 0 ? (
+
+ {t("refunded", {
+ amount: formatMoney({
+ amount: purchase.refundedAmount,
+ currency: purchase.currency,
+ }),
+ })}
+
+ ) : null}
+
+
+
+
+
+
+ );
+};
+
+export const BillingContent = ({ userId }: { userId: number }) => {
+ const t = useTranslations("core.payments.billing");
+ const { data: settings } = useSuspenseQuery(paymentsSettingsQueryOptions());
+ const purchases = useSuspenseInfiniteQuery(purchasesQueryOptions({ userId }));
+ const subscriptions = useSuspenseInfiniteQuery(
+ subscriptionsQueryOptions({ userId }),
+ );
+ const portal = useBillingPortal();
+
+ const purchaseRows = purchases.data.pages.flatMap(page => page.edges);
+ const subscriptionRows = subscriptions.data.pages.flatMap(page => page.edges);
+ const portalProviders = new Set(
+ settings.providers
+ .filter(provider => provider.capabilities.customerPortal)
+ .map(provider => provider.id),
+ );
+
+ const manage = (provider?: string) => {
+ portal.mutate(
+ { provider },
+ {
+ onError: error => {
+ toast.error(t("portal_error"), { description: error.message });
+ },
+ },
+ );
+ };
+
+ return (
+
+
+
+
+
+ {t("subscriptions.title")}
+
+ {subscriptionRows.length === 0 ? (
+
+ {t("subscriptions.empty")}
+
+ ) : (
+
+ {subscriptionRows.map(subscription => (
+ {
+ manage(subscription.provider);
+ }}
+ subscription={subscription}
+ />
+ ))}
+
+ )}
+ {subscriptions.hasNextPage ? (
+
+ ) : null}
+
+
+
+
+
+ {t("purchases.title")}
+
+ {portalProviders.size > 0 && purchaseRows.length > 0 ? (
+
+ ) : null}
+
+ {purchaseRows.length === 0 ? (
+
+ {t("purchases.empty")}
+
+ ) : (
+
+
+
+
+ {t("purchases.item")}
+ {t("purchases.date")}
+ {t("purchases.amount")}
+ {t("purchases.status")}
+
+
+
+ {purchaseRows.map(purchase => (
+
+ ))}
+
+
+
+ )}
+ {purchases.hasNextPage ? (
+
+ ) : null}
+
+
+
+ {t("tax_note")}
+
+
+ );
+};
diff --git a/packages/vitnode/src/views/payments/money-text.tsx b/packages/vitnode/src/views/payments/money-text.tsx
new file mode 100644
index 0000000000..733f5c396c
--- /dev/null
+++ b/packages/vitnode/src/views/payments/money-text.tsx
@@ -0,0 +1,57 @@
+import { useQuery } from "@tanstack/react-query";
+import { useLocale } from "use-intl";
+
+import type { CurrencyDisplay, Money } from "@/payments/money";
+
+import { formatMoney } from "@/payments/money";
+
+import { paymentsSettingsQueryOptions } from "./payments-query";
+
+/** How this install shows `currency` - `"code"` or `"symbol"` - from the public settings. */
+export const useCurrencyDisplay = (currency: string): CurrencyDisplay => {
+ const { data } = useQuery(paymentsSettingsQueryOptions());
+
+ return (
+ data?.currencies.find(item => item.code === currency)?.currencyDisplay ??
+ "symbol"
+ );
+};
+
+/** `formatMoney` bound to the reader's locale and this install's display settings. */
+export const useMoneyFormatter = () => {
+ const locale = useLocale();
+ const { data } = useQuery(paymentsSettingsQueryOptions());
+
+ return (money: Money, currencyDisplay?: CurrencyDisplay): string =>
+ formatMoney(money, {
+ currencyDisplay:
+ currencyDisplay ??
+ data?.currencies.find(item => item.code === money.currency)
+ ?.currencyDisplay ??
+ "symbol",
+ locale,
+ });
+};
+
+/**
+ * An amount in the reader's language and the record's own currency. The UI
+ * locale only decides separators and placement - it never changes the currency.
+ */
+export const MoneyText = ({
+ amount,
+ className,
+ currency,
+ currencyDisplay,
+}: Money & { className?: string; currencyDisplay?: CurrencyDisplay }) => {
+ const locale = useLocale();
+ const configured = useCurrencyDisplay(currency);
+
+ return (
+
+ {formatMoney(
+ { amount, currency },
+ { currencyDisplay: currencyDisplay ?? configured, locale },
+ )}
+
+ );
+};
diff --git a/packages/vitnode/src/views/payments/payments-query.ts b/packages/vitnode/src/views/payments/payments-query.ts
new file mode 100644
index 0000000000..e31303f9bf
--- /dev/null
+++ b/packages/vitnode/src/views/payments/payments-query.ts
@@ -0,0 +1,371 @@
+import type { QueryClient } from "@tanstack/react-query";
+
+import { infiniteQueryOptions, queryOptions } from "@tanstack/react-query";
+
+import type { CurrencyDisplay } from "@/payments/money";
+import type {
+ BillingInterval,
+ DisputeStatus,
+ FulfillmentStatus,
+ OfferMode,
+ PurchaseDisplayState,
+ PurchasePaymentStatus,
+ RefundStatus,
+ SubscriptionDisplayState,
+ SubscriptionStatus,
+} from "@/payments/status";
+
+import { CONFIG_PLUGIN } from "@/config";
+import {
+ OPERATIONAL_STALE_TIME,
+ RECORD_STALE_TIME,
+} from "@/lib/query-freshness";
+import { readApiErrorMessage } from "@/lib/read-api-error";
+import { fetcher } from "@/tanstack/fetcher";
+
+type JsonDate = Date | string;
+
+export interface PaymentsSettings {
+ currencies: { code: string; currencyDisplay: CurrencyDisplay }[];
+ defaultCurrency: null | string;
+ defaultProvider: null | string;
+ enabled: boolean;
+ providers: {
+ capabilities: {
+ currencies: string[];
+ customerPortal: boolean;
+ intervals: BillingInterval[];
+ oneTimePayments: boolean;
+ subscriptions: boolean;
+ };
+ id: string;
+ name: string;
+ }[];
+}
+
+export interface PaymentOfferPrice {
+ amount: number;
+ currency: string;
+ interval: BillingInterval | null;
+}
+
+export interface PaymentOfferSummary {
+ id: string;
+ mode: OfferMode;
+ name: string;
+ nameKey: null | string;
+ pluginId: string;
+ prices: PaymentOfferPrice[];
+}
+
+export interface Purchase {
+ amount: number;
+ createdAt: JsonDate;
+ currency: string;
+ displayState: PurchaseDisplayState;
+ disputeStatus: DisputeStatus | null;
+ fulfillmentStatus: FulfillmentStatus;
+ id: string;
+ interval: BillingInterval | null;
+ mode: OfferMode;
+ offerId: string;
+ offerName: string;
+ paidAt: JsonDate | null;
+ paymentStatus: PurchasePaymentStatus;
+ pluginId: string;
+ provider: string;
+ refundedAmount: number;
+ refundStatus: RefundStatus;
+}
+
+export interface Subscription {
+ amount: number;
+ cancelAt: JsonDate | null;
+ cancelAtPeriodEnd: boolean;
+ createdAt: JsonDate;
+ currency: string;
+ currentPeriodEnd: JsonDate | null;
+ displayState: SubscriptionDisplayState;
+ endedAt: JsonDate | null;
+ id: string;
+ interval: BillingInterval;
+ offerId: string;
+ offerName: string;
+ paidThrough: JsonDate | null;
+ pluginId: string;
+ provider: string;
+ status: SubscriptionStatus;
+}
+
+interface Page {
+ edges: T[];
+ pageInfo: { endCursor: null | string; hasNextPage: boolean };
+}
+
+/** An API refusal, with the server's `code` when it sent one. */
+export class PaymentsRequestError extends Error {
+ constructor(
+ status: number,
+ message: string,
+ code: null | string,
+ purchaseId: null | string,
+ ) {
+ super(message);
+ this.name = "PaymentsRequestError";
+ this.status = status;
+ this.code = code;
+ this.purchaseId = purchaseId;
+ }
+
+ readonly code: null | string;
+ readonly purchaseId: null | string;
+ readonly status: number;
+}
+
+export const isPaymentsRequestError = (
+ error: unknown,
+): error is PaymentsRequestError =>
+ error instanceof Error && error.name === "PaymentsRequestError";
+
+const toError = async (response: Response): Promise => {
+ const body: unknown = await response
+ .clone()
+ .json()
+ .catch(() => null);
+ const record =
+ typeof body === "object" && body !== null
+ ? (body as Record)
+ : {};
+
+ return new PaymentsRequestError(
+ response.status,
+ (await readApiErrorMessage(response)) ??
+ `The payments API answered ${response.status}.`,
+ typeof record.code === "string" ? record.code : null,
+ typeof record.purchaseId === "string" ? record.purchaseId : null,
+ );
+};
+
+/** Every billing cache entry hangs off this, keyed by the signed-in user. */
+export const PAYMENTS_QUERY_ROOT = ["vitnode", "payments"] as const;
+
+export const paymentsUserRoot = (userId: null | number) =>
+ [...PAYMENTS_QUERY_ROOT, "user", userId] as const;
+
+export const paymentsSettingsQueryOptions = () =>
+ queryOptions({
+ queryFn: async (): Promise => {
+ const response = await fetcher({
+ plugin: CONFIG_PLUGIN.pluginId,
+ method: "get",
+ module: "payments",
+ path: "/settings",
+ });
+
+ if (!response.ok) throw await toError(response);
+
+ return await response.json();
+ },
+ queryKey: [...PAYMENTS_QUERY_ROOT, "settings"] as const,
+ retry: false,
+ staleTime: 5 * RECORD_STALE_TIME,
+ });
+
+export const paymentOffersQueryOptions = ({
+ pluginId,
+ provider,
+}: {
+ pluginId: string;
+ /** Prices this provider can charge. Omit for the default provider. */
+ provider?: string;
+}) =>
+ queryOptions({
+ queryFn: async (): Promise<{
+ enabled: boolean;
+ offers: PaymentOfferSummary[];
+ }> => {
+ const response = await fetcher({
+ plugin: CONFIG_PLUGIN.pluginId,
+ args: { query: { pluginId, provider } },
+ method: "get",
+ module: "payments",
+ path: "/offers",
+ });
+
+ if (!response.ok) throw await toError(response);
+
+ return await response.json();
+ },
+ queryKey: [
+ ...PAYMENTS_QUERY_ROOT,
+ "offers",
+ pluginId,
+ provider ?? null,
+ ] as const,
+ retry: false,
+ staleTime: RECORD_STALE_TIME,
+ });
+
+const PAGE_SIZE = "10";
+
+export const purchasesQueryOptions = ({ userId }: { userId: number }) =>
+ infiniteQueryOptions({
+ getNextPageParam: (page: Page) =>
+ page.pageInfo.hasNextPage
+ ? (page.pageInfo.endCursor ?? undefined)
+ : undefined,
+ initialPageParam: null as null | string,
+ queryFn: async ({ pageParam }): Promise> => {
+ const response = await fetcher({
+ plugin: CONFIG_PLUGIN.pluginId,
+ args: {
+ query: pageParam
+ ? { cursor: pageParam, first: PAGE_SIZE }
+ : { first: PAGE_SIZE },
+ },
+ method: "get",
+ module: "payments",
+ path: "/purchases",
+ });
+
+ if (!response.ok) throw await toError(response);
+
+ return await response.json();
+ },
+ // The user is in the key although the server reads them from the session:
+ // signing in as someone else can never be served the previous account's cache.
+ queryKey: [...paymentsUserRoot(userId), "purchases"] as const,
+ retry: false,
+ staleTime: OPERATIONAL_STALE_TIME,
+ });
+
+export const subscriptionsQueryOptions = ({ userId }: { userId: number }) =>
+ infiniteQueryOptions({
+ getNextPageParam: (page: Page) =>
+ page.pageInfo.hasNextPage
+ ? (page.pageInfo.endCursor ?? undefined)
+ : undefined,
+ initialPageParam: null as null | string,
+ queryFn: async ({ pageParam }): Promise> => {
+ const response = await fetcher({
+ plugin: CONFIG_PLUGIN.pluginId,
+ args: {
+ query: pageParam
+ ? { cursor: pageParam, first: PAGE_SIZE }
+ : { first: PAGE_SIZE },
+ },
+ method: "get",
+ module: "payments",
+ path: "/subscriptions",
+ });
+
+ if (!response.ok) throw await toError(response);
+
+ return await response.json();
+ },
+ queryKey: [...paymentsUserRoot(userId), "subscriptions"] as const,
+ retry: false,
+ staleTime: OPERATIONAL_STALE_TIME,
+ });
+
+export interface PurchaseState {
+ checkoutUrl: null | string;
+ purchase: Purchase;
+}
+
+/** One purchase. `refresh` asks the server to check with the provider first. */
+export const fetchPurchase = async (
+ id: string,
+ { refresh = false }: { refresh?: boolean } = {},
+): Promise => {
+ const response = await fetcher({
+ plugin: CONFIG_PLUGIN.pluginId,
+ args: { params: { id }, query: refresh ? { refresh: "true" } : {} },
+ method: "get",
+ module: "payments",
+ path: "/purchases/{id}",
+ });
+
+ if (!response.ok) throw await toError(response);
+
+ return await response.json();
+};
+
+export interface StartCheckoutInput {
+ currency: string;
+ idempotencyKey: string;
+ interval: BillingInterval | null;
+ offerId: string;
+ pluginId: string;
+ /** A registered provider's id. Omit for the default provider. */
+ provider?: string;
+}
+
+export const startCheckoutRequest = async (
+ input: StartCheckoutInput,
+): Promise => {
+ const response = await fetcher({
+ plugin: CONFIG_PLUGIN.pluginId,
+ args: { body: input },
+ method: "post",
+ module: "payments",
+ path: "/checkout",
+ });
+
+ if (!response.ok) throw await toError(response);
+
+ return await response.json();
+};
+
+export const cancelPurchaseRequest = async (id: string): Promise => {
+ const response = await fetcher({
+ plugin: CONFIG_PLUGIN.pluginId,
+ args: { params: { id } },
+ method: "post",
+ module: "payments",
+ path: "/purchases/{id}/cancel",
+ });
+
+ if (!response.ok) throw await toError(response);
+
+ return (await response.json()).purchase;
+};
+
+export interface BillingPortalInput {
+ /** Return to this offer's page instead of Settings -> Billing. */
+ offerId?: string;
+ pluginId?: string;
+ /** A registered provider's id. Omit for the default provider. */
+ provider?: string;
+}
+
+export const openBillingPortalRequest = async ({
+ offerId,
+ pluginId,
+ provider,
+}: BillingPortalInput = {}): Promise => {
+ const response = await fetcher({
+ plugin: CONFIG_PLUGIN.pluginId,
+ args: {
+ body: {
+ provider,
+ returnTo: offerId && pluginId ? { offerId, pluginId } : undefined,
+ },
+ },
+ method: "post",
+ module: "payments",
+ path: "/portal",
+ });
+
+ if (!response.ok) throw await toError(response);
+
+ return (await response.json()).url;
+};
+
+/** After any billing change: drop every cached billing record of this user. */
+export const invalidateBilling = async (
+ queryClient: QueryClient,
+ userId: null | number,
+): Promise => {
+ await queryClient.invalidateQueries({ queryKey: paymentsUserRoot(userId) });
+};
diff --git a/packages/vitnode/src/views/payments/state-badges.tsx b/packages/vitnode/src/views/payments/state-badges.tsx
new file mode 100644
index 0000000000..4cd3f454d6
--- /dev/null
+++ b/packages/vitnode/src/views/payments/state-badges.tsx
@@ -0,0 +1,55 @@
+import { useTranslations } from "use-intl";
+
+import type {
+ PurchaseDisplayState,
+ SubscriptionDisplayState,
+} from "@/payments/status";
+
+import { Badge } from "@/components/ui/badge";
+
+type BadgeVariant =
+ | "destructive"
+ | "outline"
+ | "secondary"
+ | "success"
+ | "warning";
+
+const PURCHASE_VARIANTS: Record = {
+ awaiting_payment: "outline",
+ failed: "secondary",
+ fulfilled: "success",
+ fulfillment_failed: "warning",
+ fulfillment_pending: "warning",
+ paid: "success",
+ partially_refunded: "secondary",
+ processing: "warning",
+ refunded: "secondary",
+};
+
+const SUBSCRIPTION_VARIANTS: Record = {
+ active: "success",
+ cancellation_scheduled: "warning",
+ ended: "secondary",
+ incomplete: "outline",
+ needs_attention: "destructive",
+};
+
+export const PurchaseStateBadge = ({
+ state,
+}: {
+ state: PurchaseDisplayState;
+}) => {
+ const t = useTranslations("core.payments.purchase_state");
+
+ return {t(state)};
+};
+
+export const SubscriptionStateBadge = ({
+ state,
+}: {
+ state: SubscriptionDisplayState;
+}) => {
+ const t = useTranslations("core.payments.subscription_state");
+
+ return {t(state)};
+};
diff --git a/packages/vitnode/src/views/payments/use-checkout.ts b/packages/vitnode/src/views/payments/use-checkout.ts
new file mode 100644
index 0000000000..785d32bc9c
--- /dev/null
+++ b/packages/vitnode/src/views/payments/use-checkout.ts
@@ -0,0 +1,101 @@
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import React from "react";
+
+import type { BillingInterval } from "@/payments/status";
+
+import type { PurchaseState } from "./payments-query";
+
+import {
+ cancelPurchaseRequest,
+ invalidateBilling,
+ isPaymentsRequestError,
+ openBillingPortalRequest,
+ startCheckoutRequest,
+} from "./payments-query";
+
+export interface CheckoutChoice {
+ currency: string;
+ interval: BillingInterval | null;
+ offerId: string;
+ pluginId: string;
+ /** A registered provider's id. Omit for the default provider. */
+ provider?: string;
+}
+
+/** Errors after which repeating the same request is the right thing to do. */
+const isUncertain = (error: unknown): boolean =>
+ !isPaymentsRequestError(error) || error.code === "checkout_uncertain";
+
+/**
+ * Starts a hosted checkout and sends the browser there.
+ *
+ * The idempotency key is per choice, and kept only while the last answer was
+ * "we do not know": retrying after a timeout gets the same checkout back,
+ * while a deliberate second attempt after a real answer starts fresh. The
+ * server also deduplicates double clicks on its own, so this is the second
+ * line of defence, not the only one.
+ */
+export const useStartCheckout = ({
+ onRedirect = url => {
+ window.location.assign(url);
+ },
+ userId,
+}: {
+ onRedirect?: (url: string) => void;
+ userId: null | number;
+}) => {
+ const queryClient = useQueryClient();
+ const keyRef = React.useRef(null);
+
+ return useMutation({
+ mutationFn: async (choice: CheckoutChoice): Promise => {
+ const signature = JSON.stringify(choice);
+ if (keyRef.current?.choice !== signature) {
+ keyRef.current = { choice: signature, value: crypto.randomUUID() };
+ }
+
+ try {
+ const result = await startCheckoutRequest({
+ ...choice,
+ idempotencyKey: keyRef.current.value,
+ });
+ keyRef.current = null;
+
+ return result;
+ } catch (error) {
+ if (!isUncertain(error)) keyRef.current = null;
+ throw error;
+ }
+ },
+ onSettled: async () => {
+ await invalidateBilling(queryClient, userId);
+ },
+ onSuccess: result => {
+ if (result.checkoutUrl) onRedirect(result.checkoutUrl);
+ },
+ });
+};
+
+export const useCancelPurchase = ({ userId }: { userId: null | number }) => {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: cancelPurchaseRequest,
+ onSettled: async () => {
+ await invalidateBilling(queryClient, userId);
+ },
+ });
+};
+
+/** Opens the provider's billing portal for the signed-in user. */
+export const useBillingPortal = ({
+ onRedirect = url => {
+ window.location.assign(url);
+ },
+}: { onRedirect?: (url: string) => void } = {}) =>
+ useMutation({
+ mutationFn: openBillingPortalRequest,
+ onSuccess: url => {
+ onRedirect(url);
+ },
+ });
diff --git a/packages/vitnode/src/views/payments/use-purchase-return.test.tsx b/packages/vitnode/src/views/payments/use-purchase-return.test.tsx
new file mode 100644
index 0000000000..ae96ee587d
--- /dev/null
+++ b/packages/vitnode/src/views/payments/use-purchase-return.test.tsx
@@ -0,0 +1,169 @@
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { act, renderHook } from "@testing-library/react";
+import React from "react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+import type { Purchase } from "./payments-query";
+
+import {
+ PURCHASE_RETURN_MAX_CHECKS,
+ purchaseReturnDelay,
+ usePurchaseReturn,
+} from "./use-purchase-return";
+
+const PURCHASE_ID = "0f8fad5b-d9cb-469f-a165-70867728950e";
+
+const purchase = (overrides: Partial): Purchase => ({
+ amount: 1900,
+ createdAt: "2026-10-04T10:00:00.000Z",
+ currency: "PLN",
+ displayState: "awaiting_payment",
+ disputeStatus: null,
+ fulfillmentStatus: "not_started",
+ id: PURCHASE_ID,
+ interval: null,
+ mode: "one_time",
+ offerId: "lifetime-pass",
+ offerName: "Lifetime pass",
+ paidAt: null,
+ paymentStatus: "awaiting_payment",
+ pluginId: "@vitnode/example",
+ provider: "stripe",
+ refundedAmount: 0,
+ refundStatus: "none",
+ ...overrides,
+});
+
+/** Answers each check with the next state in `sequence`, then the last one. */
+const purchases = (sequence: Purchase[]) => {
+ const calls: { id: string; refresh?: boolean }[] = [];
+ const fetchMock = vi.fn(
+ async (id: string, options: { refresh?: boolean } = {}) => {
+ calls.push({ id, refresh: options.refresh });
+
+ return await Promise.resolve({
+ checkoutUrl: null,
+ purchase: sequence[Math.min(calls.length - 1, sequence.length - 1)],
+ });
+ },
+ );
+
+ return { calls, fetchMock };
+};
+
+const render = (
+ fetchState: ReturnType["fetchMock"],
+ onSettled = vi.fn(),
+) => {
+ const queryClient = new QueryClient();
+ const wrapper = ({ children }: { children: React.ReactNode }) => (
+ {children}
+ );
+
+ return {
+ onSettled,
+ ...renderHook(
+ () =>
+ usePurchaseReturn({
+ fetchState,
+ onSettled,
+ purchaseId: PURCHASE_ID,
+ userId: 7,
+ }),
+ { wrapper },
+ ),
+ };
+};
+
+/** Lets every timer up to `ms` fire, one poll at a time. */
+const elapse = async (ms: number) => {
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(ms);
+ });
+};
+
+describe("purchaseReturnDelay", () => {
+ it("backs off and then stays gentle", () => {
+ expect([1, 2, 3, 4, 5, 6].map(purchaseReturnDelay)).toEqual([
+ 1000, 2000, 4000, 8000, 15000, 15000,
+ ]);
+ });
+});
+
+describe("usePurchaseReturn", () => {
+ beforeEach(() => {
+ // Only the clocks: React Query schedules its own work on microtasks.
+ vi.useFakeTimers({
+ toFake: [
+ "setTimeout",
+ "clearTimeout",
+ "setInterval",
+ "clearInterval",
+ "Date",
+ ],
+ });
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it("asks the server to check with the provider, never trusts the redirect", async () => {
+ const { calls, fetchMock } = purchases([purchase({})]);
+ render(fetchMock);
+ await elapse(10);
+
+ expect(calls[0]).toEqual({ id: PURCHASE_ID, refresh: true });
+ });
+
+ it("stops polling once the purchase is settled", async () => {
+ const { fetchMock } = purchases([
+ purchase({}),
+ purchase({ displayState: "processing", paymentStatus: "processing" }),
+ purchase({
+ displayState: "fulfilled",
+ fulfillmentStatus: "fulfilled",
+ paymentStatus: "paid",
+ }),
+ ]);
+ const { onSettled, result } = render(fetchMock);
+
+ await elapse(60_000);
+
+ expect(fetchMock).toHaveBeenCalledTimes(3);
+ expect(result.current.settled).toBe(true);
+ expect(result.current.slow).toBe(false);
+ expect(onSettled).toHaveBeenCalledTimes(1);
+ });
+
+ it("gives up after a bounded number of checks instead of spinning forever", async () => {
+ const { fetchMock } = purchases([
+ purchase({ displayState: "processing", paymentStatus: "processing" }),
+ ]);
+ const { result } = render(fetchMock);
+
+ await elapse(10 * 60_000);
+
+ expect(fetchMock).toHaveBeenCalledTimes(PURCHASE_RETURN_MAX_CHECKS);
+ expect(result.current.settled).toBe(false);
+ expect(result.current.slow).toBe(true);
+
+ // A manual check still works afterwards.
+ await act(async () => {
+ await result.current.check();
+ });
+ expect(fetchMock).toHaveBeenCalledTimes(PURCHASE_RETURN_MAX_CHECKS + 1);
+ });
+
+ it("stops when the page goes away", async () => {
+ const { fetchMock } = purchases([purchase({})]);
+ const { unmount } = render(fetchMock);
+ await elapse(10);
+ const before = fetchMock.mock.calls.length;
+
+ unmount();
+ await elapse(60_000);
+
+ expect(fetchMock).toHaveBeenCalledTimes(before);
+ });
+});
diff --git a/packages/vitnode/src/views/payments/use-purchase-return.ts b/packages/vitnode/src/views/payments/use-purchase-return.ts
new file mode 100644
index 0000000000..5ccffb4c37
--- /dev/null
+++ b/packages/vitnode/src/views/payments/use-purchase-return.ts
@@ -0,0 +1,91 @@
+import { useQuery, useQueryClient } from "@tanstack/react-query";
+import React from "react";
+
+import { isPurchaseSettled } from "@/payments/status";
+
+import { fetchPurchase, paymentsUserRoot } from "./payments-query";
+
+/** Checks before giving up on automatic updates - about a minute in total. */
+export const PURCHASE_RETURN_MAX_CHECKS = 8;
+
+/** 1s, 2s, 4s, 8s, then every 15s: fast while it is likely, gentle after. */
+export const purchaseReturnDelay = (checksSoFar: number): number =>
+ Math.min(1000 * 2 ** Math.max(0, checksSoFar - 1), 15_000);
+
+/**
+ * Follows a purchase after the buyer comes back from the hosted checkout.
+ *
+ * The redirect proves nothing, so every check asks the server, which asks the
+ * provider. Polling backs off, stops on a settled state, stops after
+ * {@link PURCHASE_RETURN_MAX_CHECKS} and stops when the component unmounts -
+ * `check()` lets the buyer ask again by hand after that.
+ */
+export const usePurchaseReturn = ({
+ fetchState = fetchPurchase,
+ onSettled,
+ purchaseId,
+ userId,
+}: {
+ /** How one check is made. Defaults to the API; a test hands in its own. */
+ fetchState?: typeof fetchPurchase;
+ onSettled?: () => Promise | void;
+ purchaseId: null | string;
+ userId: null | number;
+}) => {
+ const queryClient = useQueryClient();
+ const queryKey = [
+ ...paymentsUserRoot(userId),
+ "purchase",
+ purchaseId,
+ ] as const;
+ const query = useQuery({
+ enabled: !!purchaseId && userId !== null,
+ queryFn: async () => await fetchState(purchaseId ?? "", { refresh: true }),
+ queryKey,
+ refetchInterval: ({ state }) => {
+ const purchase = state.data?.purchase;
+ if (!purchase || isPurchaseSettled(purchase)) return false;
+ if (state.dataUpdateCount >= PURCHASE_RETURN_MAX_CHECKS) return false;
+
+ return purchaseReturnDelay(state.dataUpdateCount);
+ },
+ refetchOnWindowFocus: false,
+ retry: false,
+ staleTime: 0,
+ });
+
+ const purchase = query.data?.purchase ?? null;
+ const checks = queryClient.getQueryState(queryKey)?.dataUpdateCount ?? 0;
+ const settled = purchase ? isPurchaseSettled(purchase) : false;
+ const settledRef = React.useRef(false);
+
+ React.useEffect(() => {
+ if (!settled || settledRef.current) return;
+ settledRef.current = true;
+ void (async () => {
+ // The lists only - invalidating this query too would check once more for
+ // a purchase that is already settled.
+ await Promise.all(
+ (["purchases", "subscriptions"] as const).map(
+ async list =>
+ await queryClient.invalidateQueries({
+ queryKey: [...paymentsUserRoot(userId), list],
+ }),
+ ),
+ );
+ await onSettled?.();
+ })();
+ }, [onSettled, queryClient, settled, userId]);
+
+ return {
+ check: async () => {
+ await query.refetch();
+ },
+ error: query.error,
+ isChecking: query.isFetching,
+ purchase,
+ settled,
+ /** Still unsettled after every automatic check - offer a manual one. */
+ slow: !!purchase && !settled && checks >= PURCHASE_RETURN_MAX_CHECKS,
+ };
+};
diff --git a/packages/vitnode/src/vitnode.config.ts b/packages/vitnode/src/vitnode.config.ts
index 95688bb0c7..3283363f3f 100644
--- a/packages/vitnode/src/vitnode.config.ts
+++ b/packages/vitnode/src/vitnode.config.ts
@@ -26,6 +26,7 @@ import type {
import type { VitNodeMetadata } from "./lib/metadata";
import type { BuildPluginReturn } from "./lib/plugin";
import type { PersonalInformationFieldsConfig } from "./lib/user-personal-information";
+import type { PaymentsConfig } from "./payments/config";
export type { LocaleConfig };
@@ -119,6 +120,12 @@ export interface VitNodeApiConfig {
maxBodySize?: number;
metadata: VitNodeMetadata;
notifications?: Partial;
+ /**
+ * Payments - currencies, providers and checkout settings. Leave it out and
+ * Payments is off: every payment route answers that it is disabled, and
+ * nothing else changes. API-only: never import this config into the browser.
+ */
+ payments?: PaymentsConfig;
plugins: BuildPluginApiReturn[];
rateLimiter?: Omit & {
auth?: Partial>;
diff --git a/plugins/example/package.json b/plugins/example/package.json
index 8090218522..df31beb82b 100644
--- a/plugins/example/package.json
+++ b/plugins/example/package.json
@@ -24,6 +24,8 @@
"dependencies": {
"@hono/zod-openapi": "^1.6.3",
"@tanstack/react-form": "^1.33.5",
+ "@tanstack/react-query": "^5.104.0",
+ "@tanstack/react-router": "^1.170.41",
"@vitnode/core": "workspace:*",
"drizzle-kit": "1.0.0-rc.4",
"drizzle-orm": "1.0.0-rc.4",
diff --git a/plugins/example/src/api/modules/payments/payments.module.ts b/plugins/example/src/api/modules/payments/payments.module.ts
new file mode 100644
index 0000000000..3f56e15a64
--- /dev/null
+++ b/plugins/example/src/api/modules/payments/payments.module.ts
@@ -0,0 +1,141 @@
+import type { Context } from "hono";
+
+import { buildModule } from "@vitnode/core/api/lib/module";
+import { buildRoute } from "@vitnode/core/api/lib/route";
+import { and, eq } from "drizzle-orm";
+import { HTTPException } from "hono/http-exception";
+import { createHash } from "node:crypto";
+import { z } from "zod";
+
+import { exampleOffers } from "@/api/payments/offers";
+import { hasActiveAccess } from "@/api/payments/policy";
+import { CONFIG_PLUGIN } from "@/const";
+import { example_payments_access } from "@/database/payments";
+
+const OFFER_IDS = ["lifetime-pass", "pro-plan"] as const;
+
+const zodAccess = z.object({
+ accessUntil: z.date().nullable(),
+ active: z.boolean(),
+});
+
+const readGrant = async (c: Context, userId: number, offerId: string) => {
+ const [grant] = await c
+ .get("db")
+ .select()
+ .from(example_payments_access)
+ .where(
+ and(
+ eq(example_payments_access.userId, userId),
+ eq(example_payments_access.offerId, offerId),
+ ),
+ );
+
+ return grant;
+};
+
+const accessRoute = buildRoute({
+ pluginId: CONFIG_PLUGIN.pluginId,
+ route: {
+ method: "get",
+ description:
+ "What the signed-in user can use from the example offers. Expiry is checked here, on every read.",
+ path: "/access",
+ responses: {
+ 200: {
+ content: {
+ "application/json": {
+ schema: z.object({
+ "lifetime-pass": zodAccess,
+ "pro-plan": zodAccess,
+ signedIn: z.boolean(),
+ }),
+ },
+ },
+ description: "Access per offer",
+ },
+ },
+ },
+ handler: async c => {
+ c.header("Cache-Control", "private, no-store");
+ const user = c.get("user");
+ const now = new Date();
+ const access = async (offerId: (typeof OFFER_IDS)[number]) => {
+ const grant = user ? await readGrant(c, user.id, offerId) : undefined;
+
+ return {
+ accessUntil: grant?.accessUntil ?? null,
+ active: hasActiveAccess(grant, now),
+ };
+ };
+
+ return c.json({
+ "lifetime-pass": await access("lifetime-pass"),
+ "pro-plan": await access("pro-plan"),
+ signedIn: !!user,
+ });
+ },
+});
+
+/**
+ * The protected feature. The content is produced here, only for a user whose
+ * grant is active right now - the page never receives it otherwise.
+ */
+const featureRoute = buildRoute({
+ pluginId: CONFIG_PLUGIN.pluginId,
+ route: {
+ method: "get",
+ description: "A members-only certificate for an example offer.",
+ path: "/features/{offerId}",
+ request: { params: z.object({ offerId: z.enum(OFFER_IDS) }) },
+ responses: {
+ 200: {
+ content: {
+ "application/json": {
+ schema: z.object({
+ holder: z.string(),
+ issuedAt: z.date(),
+ serial: z.string(),
+ validUntil: z.date().nullable(),
+ }),
+ },
+ },
+ description: "The unlocked feature",
+ },
+ 401: { description: "Not signed in" },
+ 403: { description: "No active access to this offer" },
+ },
+ },
+ handler: async c => {
+ c.header("Cache-Control", "private, no-store");
+ const user = c.get("user");
+ if (!user) throw new HTTPException(401, { message: "Sign in first." });
+
+ const { offerId } = c.req.valid("param");
+ const grant = await readGrant(c, user.id, offerId);
+
+ if (!grant || !hasActiveAccess(grant)) {
+ throw new HTTPException(403, {
+ message: "This feature unlocks after a confirmed payment.",
+ });
+ }
+
+ return c.json({
+ holder: user.name,
+ issuedAt: grant.createdAt,
+ serial: createHash("sha256")
+ .update(`${grant.purchaseId}:${offerId}`)
+ .digest("hex")
+ .slice(0, 12)
+ .toUpperCase(),
+ validUntil: grant.accessUntil,
+ });
+ },
+});
+
+export const paymentsModule = buildModule({
+ pluginId: CONFIG_PLUGIN.pluginId,
+ name: "payments",
+ routes: [accessRoute, featureRoute],
+ paymentOffers: exampleOffers,
+});
diff --git a/plugins/example/src/api/payments/offers.ts b/plugins/example/src/api/payments/offers.ts
new file mode 100644
index 0000000000..e1d228f629
--- /dev/null
+++ b/plugins/example/src/api/payments/offers.ts
@@ -0,0 +1,121 @@
+import { definePaymentOffer } from "@vitnode/core/payments";
+import { and, eq, isNull } from "drizzle-orm";
+
+import { example_payments_access } from "@/database/payments";
+
+import {
+ hasActiveAccess,
+ shouldRevokeAfterRefund,
+ subscriptionAccessUntil,
+} from "./policy";
+
+export const EXAMPLE_RETURN_PATH = "/example/payments";
+
+/** Bought once, kept until a full refund. */
+export const lifetimePassOffer = definePaymentOffer({
+ id: "lifetime-pass",
+ mode: "one_time",
+ name: "Example lifetime pass",
+ nameKey: "@vitnode/example.payments.offers.lifetime-pass.name",
+ returnPath: EXAMPLE_RETURN_PATH,
+ // Explicit prices per currency. No exchange rates: each one is a decision.
+ prices: { EUR: 1199, PLN: 4900, USD: 1299 },
+
+ // Runs inside the checkout lock, so two tabs cannot both pass it.
+ isEligible: async ({ tx, userId }) => {
+ const [grant] = await tx
+ .select()
+ .from(example_payments_access)
+ .where(
+ and(
+ eq(example_payments_access.userId, userId),
+ eq(example_payments_access.offerId, "lifetime-pass"),
+ ),
+ );
+
+ return hasActiveAccess(grant)
+ ? { eligible: false, reason: "You already own the lifetime pass." }
+ : { eligible: true };
+ },
+
+ // Idempotent by construction: one row per user and offer. A retry, or a
+ // repurchase after a refund, lands on the same row.
+ onPaid: async ({ purchase, tx }) => {
+ if (purchase.userId === null) return;
+
+ await tx
+ .insert(example_payments_access)
+ .values({
+ accessUntil: null,
+ offerId: purchase.offerId,
+ purchaseId: purchase.id,
+ userId: purchase.userId,
+ })
+ .onConflictDoUpdate({
+ set: { accessUntil: null, purchaseId: purchase.id, revokedAt: null },
+ target: [
+ example_payments_access.userId,
+ example_payments_access.offerId,
+ ],
+ });
+ },
+
+ onRefunded: async ({ purchase, tx }) => {
+ if (!shouldRevokeAfterRefund(purchase)) return;
+
+ // Only the grant this purchase created - never one a later purchase made.
+ await tx
+ .update(example_payments_access)
+ .set({ revokedAt: new Date() })
+ .where(
+ and(
+ eq(example_payments_access.purchaseId, purchase.id),
+ isNull(example_payments_access.revokedAt),
+ ),
+ );
+ },
+});
+
+/** Monthly or yearly, renewed automatically until cancelled. */
+export const proPlanOffer = definePaymentOffer({
+ id: "pro-plan",
+ mode: "subscription",
+ name: "Example Pro plan",
+ nameKey: "@vitnode/example.payments.offers.pro-plan.name",
+ returnPath: EXAMPLE_RETURN_PATH,
+ intervals: {
+ month: { EUR: 449, PLN: 1900, USD: 499 },
+ year: { EUR: 4490, PLN: 19000, USD: 4990 },
+ },
+
+ // Set, never add: the same subscription state can arrive more than once.
+ onSubscriptionChanged: async ({ subscription, tx }) => {
+ if (subscription.userId === null) return;
+
+ const accessUntil = subscriptionAccessUntil(subscription);
+ // No verified payment yet - nothing to grant. `null` would mean "forever".
+ if (!accessUntil) return;
+
+ await tx
+ .insert(example_payments_access)
+ .values({
+ accessUntil,
+ offerId: subscription.offerId,
+ purchaseId: subscription.purchaseId,
+ userId: subscription.userId,
+ })
+ .onConflictDoUpdate({
+ set: {
+ accessUntil,
+ purchaseId: subscription.purchaseId,
+ revokedAt: null,
+ },
+ target: [
+ example_payments_access.userId,
+ example_payments_access.offerId,
+ ],
+ });
+ },
+});
+
+export const exampleOffers = [lifetimePassOffer, proPlanOffer];
diff --git a/plugins/example/src/api/payments/policy.test.ts b/plugins/example/src/api/payments/policy.test.ts
new file mode 100644
index 0000000000..e4842cf310
--- /dev/null
+++ b/plugins/example/src/api/payments/policy.test.ts
@@ -0,0 +1,69 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ hasActiveAccess,
+ shouldRevokeAfterRefund,
+ subscriptionAccessUntil,
+} from "./policy";
+
+const now = new Date("2026-10-04T12:00:00Z");
+
+describe("hasActiveAccess", () => {
+ it("keeps a one-time grant with no end date", () => {
+ expect(hasActiveAccess({ accessUntil: null, revokedAt: null }, now)).toBe(
+ true,
+ );
+ });
+
+ it("enforces expiry at read time, without waiting for a job", () => {
+ expect(
+ hasActiveAccess(
+ { accessUntil: new Date("2026-10-04T11:59:59Z"), revokedAt: null },
+ now,
+ ),
+ ).toBe(false);
+ expect(
+ hasActiveAccess(
+ { accessUntil: new Date("2026-10-04T12:00:01Z"), revokedAt: null },
+ now,
+ ),
+ ).toBe(true);
+ });
+
+ it("refuses a revoked grant and a missing one", () => {
+ expect(hasActiveAccess({ accessUntil: null, revokedAt: now }, now)).toBe(
+ false,
+ );
+ expect(hasActiveAccess(undefined, now)).toBe(false);
+ });
+});
+
+describe("shouldRevokeAfterRefund", () => {
+ it("revokes only on a full refund", () => {
+ expect(shouldRevokeAfterRefund({ refundStatus: "full" })).toBe(true);
+ expect(shouldRevokeAfterRefund({ refundStatus: "partial" })).toBe(false);
+ expect(shouldRevokeAfterRefund({ refundStatus: "none" })).toBe(false);
+ });
+});
+
+describe("subscriptionAccessUntil", () => {
+ const paidThrough = new Date("2026-11-04T12:00:00Z");
+
+ it("grants nothing before the first verified payment", () => {
+ expect(
+ subscriptionAccessUntil({ endedAt: null, paidThrough: null }),
+ ).toBeNull();
+ });
+
+ it("lasts until the end of the paid period, including after a scheduled cancellation", () => {
+ expect(subscriptionAccessUntil({ endedAt: null, paidThrough })).toEqual(
+ paidThrough,
+ );
+ });
+
+ it("ends early when the subscription ended early", () => {
+ const endedAt = new Date("2026-10-10T00:00:00Z");
+
+ expect(subscriptionAccessUntil({ endedAt, paidThrough })).toEqual(endedAt);
+ });
+});
diff --git a/plugins/example/src/api/payments/policy.ts b/plugins/example/src/api/payments/policy.ts
new file mode 100644
index 0000000000..1d1396ada9
--- /dev/null
+++ b/plugins/example/src/api/payments/policy.ts
@@ -0,0 +1,38 @@
+import type {
+ PaymentPurchaseSnapshot,
+ PaymentSubscriptionSnapshot,
+} from "@vitnode/core/payments";
+
+/** Whether a stored grant is usable at `now`. Checked on every read. */
+export const hasActiveAccess = (
+ grant: undefined | { accessUntil: Date | null; revokedAt: Date | null },
+ now: Date = new Date(),
+): boolean =>
+ !!grant &&
+ grant.revokedAt === null &&
+ (grant.accessUntil === null || grant.accessUntil > now);
+
+/**
+ * The example's refund policy: only a confirmed refund of the whole price
+ * takes the one-time purchase away. A partial refund is a goodwill gesture.
+ */
+export const shouldRevokeAfterRefund = (
+ purchase: Pick,
+): boolean => purchase.refundStatus === "full";
+
+/**
+ * How long a subscriber keeps the feature: until the end of the last period
+ * a verified payment covers. A failed renewal does not shorten it, a scheduled
+ * cancellation keeps it until then, and a subscription that ended early ends it
+ * when it ended.
+ */
+export const subscriptionAccessUntil = (
+ subscription: Pick,
+): Date | null => {
+ const { endedAt, paidThrough } = subscription;
+
+ if (!paidThrough) return null;
+ if (endedAt && endedAt < paidThrough) return endedAt;
+
+ return paidThrough;
+};
diff --git a/plugins/example/src/config.api.ts b/plugins/example/src/config.api.ts
index 777f0e63e1..730439998f 100644
--- a/plugins/example/src/config.api.ts
+++ b/plugins/example/src/config.api.ts
@@ -5,6 +5,7 @@ import { buildContentPublicModule } from "@vitnode/core/content/server";
import { articleExcerptAiAction } from "@/api/ai/actions";
import { adminModule } from "@/api/modules/admin/admin.module";
+import { paymentsModule } from "@/api/modules/payments/payments.module";
import { CONFIG_PLUGIN } from "@/const";
import { settingsPage } from "@/content/settings-page";
import { advancedArticleContent } from "@/database/advanced-articles";
@@ -32,6 +33,7 @@ export const exampleApiPlugin = () =>
},
modules: [
adminModule,
+ paymentsModule,
buildContentPublicModule({
pluginId: CONFIG_PLUGIN.pluginId,
contentTypes: [
diff --git a/plugins/example/src/database/payments.ts b/plugins/example/src/database/payments.ts
new file mode 100644
index 0000000000..1b9a6c1f64
--- /dev/null
+++ b/plugins/example/src/database/payments.ts
@@ -0,0 +1,38 @@
+import { core_users } from "@vitnode/core/database/users";
+import { camelCase, uniqueIndex } from "drizzle-orm/pg-core";
+
+/**
+ * What the example plugin's offers unlock. Core owns billing; this table is the
+ * plugin's own business state - one row per user and offer.
+ *
+ * `accessUntil` is `null` for something bought once and kept, and the paid-
+ * through date for a subscription. Every read compares it with the clock, so
+ * access ends on time even if nothing runs at that moment.
+ */
+export const example_payments_access = camelCase.table.withRLS(
+ "example_payments_access",
+ t => ({
+ id: t.serial().primaryKey(),
+ userId: t
+ .integer()
+ .references(() => core_users.id, { onDelete: "cascade" })
+ .notNull(),
+ offerId: t.varchar({ length: 64 }).notNull(),
+ /** Core's public purchase id that granted it. */
+ purchaseId: t.uuid().notNull(),
+ accessUntil: t.timestamp(),
+ revokedAt: t.timestamp(),
+ createdAt: t.timestamp().notNull().defaultNow(),
+ updatedAt: t
+ .timestamp()
+ .notNull()
+ .defaultNow()
+ .$onUpdate(() => new Date()),
+ }),
+ t => [
+ uniqueIndex("example_payments_access_user_offer_unique").on(
+ t.userId,
+ t.offerId,
+ ),
+ ],
+);
diff --git a/plugins/example/src/locales/en.json b/plugins/example/src/locales/en.json
index 0da3175550..d216bf0de9 100644
--- a/plugins/example/src/locales/en.json
+++ b/plugins/example/src/locales/en.json
@@ -129,6 +129,43 @@
"published": "Published",
"faq": "Frequently asked questions"
},
+ "payments": {
+ "title": "Payments example",
+ "desc": "Buy a lifetime pass or subscribe to the Pro plan. Each unlocks a members-only certificate that the API only returns after a confirmed payment.",
+ "offers": {
+ "lifetime-pass": {
+ "name": "Example lifetime pass"
+ },
+ "pro-plan": {
+ "name": "Example Pro plan"
+ }
+ },
+ "lifetime": {
+ "title": "Lifetime pass",
+ "desc": "Pay once, keep it. A full refund takes it away again; a partial refund does not."
+ },
+ "plan": {
+ "title": "Pro plan",
+ "desc": "Billed monthly or yearly and renewed automatically. Cancel any time - you keep access until the end of the paid period."
+ },
+ "owned": "Owned",
+ "subscribed": "Subscribed",
+ "locked": "Unlocks after a confirmed payment.",
+ "access_until": "Pro access lasts until {date}.",
+ "open_checkout": "You already started a checkout for this. Continue it, or cancel it to start over.",
+ "canceled": "Checkout canceled. You have not been charged.",
+ "sign_in": "Sign in to buy or subscribe.",
+ "sign_in_link": "Sign in",
+ "test_mode": "In Stripe test mode, pay with card 4242 4242 4242 4242, any future expiry date and any CVC. No real money moves.",
+ "feature": {
+ "title": "Supporter certificate",
+ "holder": "Issued to {name}",
+ "serial": "Serial {serial}",
+ "valid_until": "Valid until {date}",
+ "loading": "Loading your certificate…",
+ "error": "The certificate could not be loaded."
+ }
+ },
"events": {
"sessions": "Sessions"
}
diff --git a/plugins/example/src/pages/payments/example-payments-query.ts b/plugins/example/src/pages/payments/example-payments-query.ts
new file mode 100644
index 0000000000..4c0ff66374
--- /dev/null
+++ b/plugins/example/src/pages/payments/example-payments-query.ts
@@ -0,0 +1,74 @@
+import { queryOptions } from "@tanstack/react-query";
+import { fetcher } from "@vitnode/core/tanstack/fetcher";
+
+import { CONFIG_PLUGIN } from "@/const";
+
+export const EXAMPLE_OFFERS = {
+ lifetime: "lifetime-pass",
+ plan: "pro-plan",
+} as const;
+
+export type ExampleOfferId =
+ (typeof EXAMPLE_OFFERS)[keyof typeof EXAMPLE_OFFERS];
+
+const root = (userId: null | number) =>
+ ["@vitnode/example", "payments", userId] as const;
+
+export const exampleAccessQueryOptions = ({
+ userId,
+}: {
+ userId: null | number;
+}) =>
+ queryOptions({
+ queryFn: async () => {
+ const response = await fetcher({
+ plugin: CONFIG_PLUGIN.pluginId,
+ method: "get",
+ module: "payments",
+ path: "/access",
+ });
+
+ if (!response.ok) {
+ throw new Error(`The example access API answered ${response.status}.`);
+ }
+
+ return await response.json();
+ },
+ queryKey: [...root(userId), "access"] as const,
+ retry: false,
+ staleTime: 15_000,
+ });
+
+/**
+ * The protected feature itself. Only requested once access says it is active,
+ * and the server checks again - hiding it in the page would protect nothing.
+ */
+export const exampleFeatureQueryOptions = ({
+ offerId,
+ userId,
+}: {
+ offerId: ExampleOfferId;
+ userId: null | number;
+}) =>
+ queryOptions({
+ queryFn: async () => {
+ const response = await fetcher({
+ plugin: CONFIG_PLUGIN.pluginId,
+ args: { params: { offerId } },
+ method: "get",
+ module: "payments",
+ path: "/features/{offerId}",
+ });
+
+ if (!response.ok) {
+ throw new Error(`The example feature API answered ${response.status}.`);
+ }
+
+ return await response.json();
+ },
+ queryKey: [...root(userId), "feature", offerId] as const,
+ retry: false,
+ staleTime: 60_000,
+ });
+
+export const exampleAccessRoot = root;
diff --git a/plugins/example/src/pages/payments/payments-page.tsx b/plugins/example/src/pages/payments/payments-page.tsx
new file mode 100644
index 0000000000..62be7299c5
--- /dev/null
+++ b/plugins/example/src/pages/payments/payments-page.tsx
@@ -0,0 +1,786 @@
+import type { PluginRoutePageProps } from "@vitnode/core/routing";
+import type {
+ PaymentOfferSummary,
+ PaymentsSettings,
+ Subscription,
+} from "@vitnode/core/tanstack/payments";
+
+import {
+ useQuery,
+ useQueryClient,
+ useSuspenseInfiniteQuery,
+ useSuspenseQuery,
+} from "@tanstack/react-query";
+import { Link } from "@tanstack/react-router";
+import {
+ Alert,
+ AlertDescription,
+ AlertTitle,
+} from "@vitnode/core/components/ui/alert";
+import { Badge } from "@vitnode/core/components/ui/badge";
+import { Button } from "@vitnode/core/components/ui/button";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardFooter,
+ CardHeader,
+ CardTitle,
+} from "@vitnode/core/components/ui/card";
+import {
+ NativeSelect,
+ NativeSelectOption,
+} from "@vitnode/core/components/ui/native-select";
+import { Skeleton } from "@vitnode/core/components/ui/skeleton";
+import {
+ ToggleGroup,
+ ToggleGroupItem,
+} from "@vitnode/core/components/ui/toggle-group";
+import { ensureAuthState, useSessionQuery } from "@vitnode/core/tanstack/auth";
+import {
+ fetchPurchase,
+ isPaymentsRequestError,
+ paymentOffersQueryOptions,
+ paymentsSettingsQueryOptions,
+ requirePaymentsEnabled,
+ subscriptionsQueryOptions,
+ SubscriptionStateBadge,
+ useBillingPortal,
+ useCancelPurchase,
+ useMoneyFormatter,
+ usePurchaseReturn,
+ useStartCheckout,
+} from "@vitnode/core/tanstack/payments";
+import { defineRoute } from "@vitnode/core/tanstack/plugin-routes";
+import {
+ BadgeCheckIcon,
+ CreditCardIcon,
+ ExternalLinkIcon,
+ LockIcon,
+ RefreshCwIcon,
+} from "lucide-react";
+import React from "react";
+import { toast } from "sonner";
+import { useFormatter, useTranslations } from "use-intl";
+
+import { CONFIG_PLUGIN } from "@/const";
+
+import type { ExampleOfferId } from "./example-payments-query";
+import type { PaymentsSearch } from "./payments-search";
+
+import {
+ EXAMPLE_OFFERS,
+ exampleAccessQueryOptions,
+ exampleAccessRoot,
+ exampleFeatureQueryOptions,
+} from "./example-payments-query";
+
+interface PaymentsPageData {
+ userId: null | number;
+}
+
+type Interval = "month" | "year";
+
+const toDate = (value: Date | string) =>
+ typeof value === "string" ? new Date(value) : value;
+
+/** Currencies a buyer can choose: enabled ones that at least one offer is priced in. */
+const choosableCurrencies = (
+ settings: PaymentsSettings,
+ offers: PaymentOfferSummary[],
+): string[] =>
+ settings.currencies
+ .map(currency => currency.code)
+ .filter(code =>
+ offers.some(offer => offer.prices.some(price => price.currency === code)),
+ );
+
+/** What happened to the purchase the buyer just came back from. */
+const ReturnStatus = ({
+ purchaseId,
+ userId,
+}: {
+ purchaseId: string;
+ userId: number;
+}) => {
+ const t = useTranslations("core.payments.return");
+ const queryClient = useQueryClient();
+ const onSettled = React.useCallback(async () => {
+ await queryClient.invalidateQueries({
+ queryKey: exampleAccessRoot(userId),
+ });
+ }, [queryClient, userId]);
+ const { check, error, isChecking, purchase, settled, slow } =
+ usePurchaseReturn({ onSettled, purchaseId, userId });
+
+ let variant: "destructive" | "info" | "success" | "warning" = "info";
+ let message = t("checking");
+
+ if (error) {
+ variant = "warning";
+ message = t("error");
+ } else if (purchase) {
+ switch (purchase.displayState) {
+ case "awaiting_payment":
+ message = t("checking");
+ break;
+ case "failed":
+ variant = "destructive";
+ message = t("failed");
+ break;
+ case "fulfillment_failed":
+ variant = "warning";
+ message = t("fulfillment_failed");
+ break;
+ case "fulfillment_pending":
+ case "paid":
+ variant = "success";
+ message = t("paid");
+ break;
+ case "processing":
+ message = t("processing");
+ break;
+ default:
+ variant = "success";
+ message = t("fulfilled");
+ }
+ }
+
+ return (
+
+
+ {message}
+ {slow ? {t("slow")} : null}
+ {!settled && (slow || error) ? (
+
+ ) : null}
+
+
+ );
+};
+
+/** The members-only content, fetched from a route that checks access itself. */
+const FeatureCertificate = ({
+ offerId,
+ userId,
+}: {
+ offerId: ExampleOfferId;
+ userId: number;
+}) => {
+ const t = useTranslations("@vitnode/example.payments.feature");
+ const format = useFormatter();
+ const { data, isError, isPending } = useQuery(
+ exampleFeatureQueryOptions({ offerId, userId }),
+ );
+
+ if (isPending) {
+ return (
+
+ {t("loading")}
+
+
+
+ );
+ }
+
+ if (isError) {
+ return {t("error")}
;
+ }
+
+ return (
+
+
+
+ {t("title")}
+
+ {t("holder", { name: data.holder })}
+
+ {t("serial", { serial: data.serial })}
+
+ {data.validUntil ? (
+
+ {t("valid_until", {
+ date: format.dateTime(toDate(data.validUntil), {
+ dateStyle: "long",
+ }),
+ })}
+
+ ) : null}
+
+ );
+};
+
+const OpenCheckoutNotice = ({
+ onDone,
+ purchaseId,
+ userId,
+}: {
+ onDone: () => void;
+ purchaseId: string;
+ userId: number;
+}) => {
+ const t = useTranslations("core.payments.checkout");
+ const tPage = useTranslations("@vitnode/example.payments");
+ const cancel = useCancelPurchase({ userId });
+ const [isContinuing, setIsContinuing] = React.useState(false);
+
+ return (
+
+ {tPage("open_checkout")}
+
+
+
+
+
+ );
+};
+
+const useCheckoutAction = (userId: null | number) => {
+ const t = useTranslations("core.payments.checkout");
+ const checkout = useStartCheckout({ userId });
+ const [openPurchase, setOpenPurchase] = React.useState(null);
+
+ const start = (
+ offerId: ExampleOfferId,
+ currency: string,
+ interval: Interval | null,
+ ) => {
+ checkout.mutate(
+ { currency, interval, offerId, pluginId: CONFIG_PLUGIN.pluginId },
+ {
+ onError: error => {
+ if (
+ isPaymentsRequestError(error) &&
+ error.code === "checkout_in_progress" &&
+ error.purchaseId
+ ) {
+ setOpenPurchase(error.purchaseId);
+
+ return;
+ }
+
+ toast.error(t("error"), {
+ description:
+ isPaymentsRequestError(error) &&
+ error.code === "checkout_uncertain"
+ ? t("retry_hint")
+ : error.message,
+ });
+ },
+ },
+ );
+ };
+
+ return { checkout, openPurchase, setOpenPurchase, start };
+};
+
+const PriceLine = ({
+ amount,
+ currency,
+ interval,
+}: {
+ amount: number;
+ currency: string;
+ interval: Interval | null;
+}) => {
+ const t = useTranslations("core.payments");
+ const formatMoney = useMoneyFormatter();
+ const price = formatMoney({ amount, currency });
+
+ return (
+
+ {interval ? t(`per_interval.${interval}`, { price }) : price}
+
+ );
+};
+
+const PaymentsPage = ({
+ loaderData,
+ navigate,
+ search,
+}: PluginRoutePageProps) => {
+ const t = useTranslations("@vitnode/example.payments");
+ const tCheckout = useTranslations("core.payments.checkout");
+ const tState = useTranslations("core.payments");
+ const format = useFormatter();
+ const { data: session } = useSessionQuery();
+ const userId = session?.user?.id ?? loaderData.userId;
+ const { data: settings } = useSuspenseQuery(paymentsSettingsQueryOptions());
+ const { data: offerList } = useSuspenseQuery(
+ paymentOffersQueryOptions({ pluginId: CONFIG_PLUGIN.pluginId }),
+ );
+ const { data: access } = useSuspenseQuery(
+ exampleAccessQueryOptions({ userId }),
+ );
+ const [interval, setInterval] = React.useState("month");
+ const { checkout, openPurchase, setOpenPurchase, start } =
+ useCheckoutAction(userId);
+ const portal = useBillingPortal();
+
+ const currencies = choosableCurrencies(settings, offerList.offers);
+ const currency =
+ search.currency && currencies.includes(search.currency)
+ ? search.currency
+ : ((settings.defaultCurrency &&
+ currencies.includes(settings.defaultCurrency)
+ ? settings.defaultCurrency
+ : currencies[0]) ?? null);
+
+ const lifetime = offerList.offers.find(
+ offer => offer.id === EXAMPLE_OFFERS.lifetime,
+ );
+ const plan = offerList.offers.find(offer => offer.id === EXAMPLE_OFFERS.plan);
+ const priceOf = (
+ offer: PaymentOfferSummary | undefined,
+ chosen: Interval | null,
+ ) =>
+ offer?.prices.find(
+ price => price.currency === currency && price.interval === chosen,
+ );
+
+ const lifetimePrice = priceOf(lifetime, null);
+ const planPrice = priceOf(plan, interval);
+ const owned = access["lifetime-pass"].active;
+ const subscribed = access["pro-plan"].active;
+ const busy =
+ checkout.isPending || (checkout.isSuccess && !!checkout.data.checkoutUrl);
+
+ return (
+
+
+
+
+ {t("title")}
+
+
+ {t("desc")}
+
+
+
+ {currency ? (
+
+ ) : null}
+
+
+ {search.purchase && userId !== null ? (
+
+ ) : null}
+
+ {search.checkout === "canceled" && !search.purchase ? (
+
+ {t("canceled")}
+
+ ) : null}
+
+ {userId === null ? (
+
+
+ {t("sign_in")}
+
+ {t("sign_in_link")}
+
+
+
+ ) : null}
+
+ {openPurchase && userId !== null ? (
+
{
+ setOpenPurchase(null);
+ }}
+ purchaseId={openPurchase}
+ userId={userId}
+ />
+ ) : null}
+
+
+
+
+
+ {t("lifetime.title")}
+ {owned ? {t("owned")} : null}
+
+
+ {t("lifetime.desc")}
+
+
+
+ {lifetimePrice ? (
+
+ ) : (
+
+ {tCheckout("unavailable")}
+
+ )}
+ {owned && userId !== null ? (
+
+ ) : (
+
+
+ {t("locked")}
+
+ )}
+
+ {owned ? null : (
+
+
+
+ )}
+
+
+
{
+ portal.mutate(
+ {
+ offerId: EXAMPLE_OFFERS.plan,
+ pluginId: CONFIG_PLUGIN.pluginId,
+ },
+ {
+ onError: error => {
+ toast.error(tState("billing.portal_error"), {
+ description: error.message,
+ });
+ },
+ },
+ );
+ }}
+ onSubscribe={() => {
+ if (currency) start(EXAMPLE_OFFERS.plan, currency, interval);
+ }}
+ plan={plan}
+ planPrice={planPrice}
+ portalPending={portal.isPending}
+ subscribed={subscribed}
+ userId={userId}
+ />
+
+
+ {userId !== null && access["pro-plan"].accessUntil ? (
+
+ {t("access_until", {
+ date: format.dateTime(toDate(access["pro-plan"].accessUntil), {
+ dateStyle: "long",
+ }),
+ })}
+
+ ) : null}
+
+
+ {t("test_mode")}
+
+
+ );
+};
+
+/** The buyer's live subscription to the example plan, if they have one. */
+const usePlanSubscription = (userId: number): null | Subscription => {
+ const { data } = useSuspenseInfiniteQuery(
+ subscriptionsQueryOptions({ userId }),
+ );
+
+ return (
+ data.pages
+ .flatMap(page => page.edges)
+ .find(
+ subscription =>
+ subscription.pluginId === CONFIG_PLUGIN.pluginId &&
+ subscription.offerId === EXAMPLE_OFFERS.plan &&
+ subscription.displayState !== "ended",
+ ) ?? null
+ );
+};
+
+const SubscriptionSummary = ({
+ subscription,
+}: {
+ subscription: Subscription;
+}) => {
+ const t = useTranslations("core.payments");
+ const format = useFormatter();
+ const date = (value: Date | string) =>
+ format.dateTime(toDate(value), { dateStyle: "long" });
+
+ return (
+
+
+
+
+ {t(`interval.${subscription.interval}`)}
+
+
+ {subscription.paidThrough ? (
+
+ {t("billing.subscriptions.paid_through", {
+ date: date(subscription.paidThrough),
+ })}
+
+ ) : null}
+ {subscription.currentPeriodEnd ? (
+
+ {t(
+ subscription.cancelAtPeriodEnd
+ ? "billing.subscriptions.ends"
+ : "billing.subscriptions.renews",
+ { date: date(subscription.currentPeriodEnd) },
+ )}
+
+ ) : null}
+ {subscription.displayState === "needs_attention" ? (
+
+
+ {t("billing.subscriptions.attention")}
+
+
+ ) : null}
+
+ );
+};
+
+const PlanCard = ({
+ busy,
+ canBuy,
+ interval,
+ onIntervalChange,
+ onManage,
+ onSubscribe,
+ plan,
+ planPrice,
+ portalPending,
+ subscribed,
+ userId,
+}: {
+ busy: boolean;
+ canBuy: boolean;
+ interval: Interval;
+ onIntervalChange: (interval: Interval) => void;
+ onManage: () => void;
+ onSubscribe: () => void;
+ plan: PaymentOfferSummary | undefined;
+ planPrice: undefined | { amount: number; currency: string };
+ portalPending: boolean;
+ subscribed: boolean;
+ userId: null | number;
+}) => {
+ const t = useTranslations("@vitnode/example.payments");
+ const tCheckout = useTranslations("core.payments.checkout");
+ const tState = useTranslations("core.payments");
+ const intervals = (["month", "year"] as const).filter(item =>
+ plan?.prices.some(price => price.interval === item),
+ );
+
+ return (
+
+
+
+ {t("plan.title")}
+ {subscribed ? (
+ {t("subscribed")}
+ ) : null}
+
+
+ {t("plan.desc")}
+
+
+
+ {intervals.length > 1 ? (
+ {
+ const picked = next[0];
+ if (picked === "month" || picked === "year")
+ onIntervalChange(picked);
+ }}
+ spacing={0.5}
+ value={[interval]}
+ >
+ {intervals.map(item => (
+
+ {tState(`interval.${item}`)}
+
+ ))}
+
+ ) : null}
+ {planPrice ? (
+
+ ) : (
+ {tCheckout("unavailable")}
+ )}
+ {userId === null ? null : (
+ }>
+
+
+ )}
+ {subscribed && userId !== null ? (
+
+ ) : (
+
+
+ {t("locked")}
+
+ )}
+
+
+ {subscribed || userId === null ? null : (
+
+ )}
+ {userId !== null && subscribed ? (
+
+ ) : null}
+
+
+ );
+};
+
+const PlanSubscriptionSlot = ({ userId }: { userId: number }) => {
+ const subscription = usePlanSubscription(userId);
+
+ return subscription ? (
+
+ ) : null;
+};
+
+export const route = defineRoute({
+ // `head` after `load`, always.
+ load: async ({ context }) => {
+ const auth = await ensureAuthState(context.queryClient);
+ const userId = auth.user?.id ?? null;
+
+ // No provider configured: the page does not exist.
+ await requirePaymentsEnabled(context.queryClient);
+
+ await Promise.all([
+ context.queryClient.query({
+ ...paymentOffersQueryOptions({ pluginId: CONFIG_PLUGIN.pluginId }),
+ staleTime: "static",
+ }),
+ context.queryClient.query({
+ ...exampleAccessQueryOptions({ userId }),
+ staleTime: "static",
+ }),
+ userId === null
+ ? undefined
+ : context.queryClient.infiniteQuery({
+ ...subscriptionsQueryOptions({ userId }),
+ staleTime: "static",
+ }),
+ ]);
+
+ return { userId };
+ },
+ head: ({ t }) => ({
+ description: t("@vitnode/example.payments.desc"),
+ title: t("@vitnode/example.payments.title"),
+ }),
+
+ breadcrumb: false,
+});
+
+export default PaymentsPage;
diff --git a/plugins/example/src/pages/payments/payments-search.test.ts b/plugins/example/src/pages/payments/payments-search.test.ts
new file mode 100644
index 0000000000..43bb7029f2
--- /dev/null
+++ b/plugins/example/src/pages/payments/payments-search.test.ts
@@ -0,0 +1,30 @@
+import { describe, expect, it } from "vitest";
+
+import { paymentsSearch } from "./payments-search";
+
+describe("paymentsSearch", () => {
+ it("keeps a purchase id, a currency and a canceled return", () => {
+ expect(
+ paymentsSearch({
+ checkout: "canceled",
+ currency: "USD",
+ purchase: "0f8fad5b-d9cb-469f-a165-70867728950e",
+ }),
+ ).toEqual({
+ checkout: "canceled",
+ currency: "USD",
+ purchase: "0f8fad5b-d9cb-469f-a165-70867728950e",
+ });
+ });
+
+ it("drops anything that is not one of those", () => {
+ expect(
+ paymentsSearch({
+ checkout: "paid",
+ currency: "usd",
+ purchase: "../../admin",
+ redirect: "https://evil.example",
+ }),
+ ).toEqual({});
+ });
+});
diff --git a/plugins/example/src/pages/payments/payments-search.ts b/plugins/example/src/pages/payments/payments-search.ts
new file mode 100644
index 0000000000..cb379ed69a
--- /dev/null
+++ b/plugins/example/src/pages/payments/payments-search.ts
@@ -0,0 +1,23 @@
+export interface PaymentsSearch {
+ /** `canceled` when the buyer came back with the back button on the hosted page. */
+ checkout?: "canceled";
+ /** The billing currency the buyer picked. Independent of the UI language. */
+ currency?: string;
+ /** Set by the checkout return URL - the purchase to follow. */
+ purchase?: string;
+}
+
+const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
+
+/** Normalises rather than rejects: anything unexpected is simply dropped. */
+export const paymentsSearch = (
+ input: Record,
+): PaymentsSearch => ({
+ ...(input.checkout === "canceled" ? { checkout: "canceled" as const } : {}),
+ ...(typeof input.currency === "string" && /^[A-Z]{3}$/.test(input.currency)
+ ? { currency: input.currency }
+ : {}),
+ ...(typeof input.purchase === "string" && UUID.test(input.purchase)
+ ? { purchase: input.purchase }
+ : {}),
+});
diff --git a/plugins/example/src/routes.ts b/plugins/example/src/routes.ts
index c16b37fd64..ecc0914d7e 100644
--- a/plugins/example/src/routes.ts
+++ b/plugins/example/src/routes.ts
@@ -9,6 +9,7 @@ import {
import type { ExampleSearch } from "./pages/example-page";
import { browseSearch } from "./pages/browse-search";
+import { paymentsSearch } from "./pages/payments/payments-search";
import { zonesSearch } from "./pages/zones-search";
export const routes = definePluginRoutes([
@@ -25,6 +26,12 @@ export const routes = definePluginRoutes([
search: browseSearch,
}),
+ page("/example/payments", {
+ component: lazy(() => import("./pages/payments/payments-page")),
+ messages: ["@vitnode/example.payments", "core.payments"],
+ search: paymentsSearch,
+ }),
+
page("/example/zones", {
component: lazy(() => import("./pages/zones-page")),
search: zonesSearch,
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 1db8c9fb58..ab502f4174 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -62,6 +62,9 @@ importers:
'@vitnode/node-cron':
specifier: workspace:*
version: link:../../packages/node-cron
+ '@vitnode/stripe':
+ specifier: workspace:*
+ version: link:../../packages/stripe
'@vitnode/supabase-storage':
specifier: workspace:*
version: link:../../packages/supabase-storage
@@ -186,6 +189,12 @@ importers:
'@vitnode/example':
specifier: workspace:*
version: link:../../plugins/example
+ '@vitnode/node-cron':
+ specifier: workspace:*
+ version: link:../../packages/node-cron
+ '@vitnode/stripe':
+ specifier: workspace:*
+ version: link:../../packages/stripe
'@vitnode/supabase-storage':
specifier: workspace:*
version: link:../../packages/supabase-storage
@@ -498,6 +507,40 @@ importers:
specifier: ^6.0.3
version: 6.0.3
+ packages/stripe:
+ dependencies:
+ stripe:
+ specifier: ^23.0.0
+ version: 23.0.0(@types/node@26.6.4)
+ devDependencies:
+ '@swc/cli':
+ specifier: ^0.8.1
+ version: 0.8.1(@swc/core@1.16.13(@swc/helpers@0.5.23))(chokidar@5.0.0)
+ '@swc/core':
+ specifier: ^1.16.13
+ version: 1.16.13(@swc/helpers@0.5.23)
+ '@vitnode/config':
+ specifier: workspace:*
+ version: link:../config
+ '@vitnode/core':
+ specifier: workspace:*
+ version: link:../vitnode
+ oxlint:
+ specifier: ^1.87.0
+ version: 1.87.0(oxlint-tsgolint@7.0.2003)
+ oxlint-tsgolint:
+ specifier: ^7.0.2003
+ version: 7.0.2003
+ tsc-alias:
+ specifier: ^1.9.7
+ version: 1.9.7
+ typescript:
+ specifier: ^6.0.3
+ version: 6.0.3
+ vitest:
+ specifier: ^5.0.3
+ version: 5.0.3(@opentelemetry/api@1.9.1)(@types/node@26.6.4)(@vitest/coverage-v8@5.0.3)(happy-dom@20.14.6)(jsdom@30.1.2)(vite@8.3.4(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))
+
packages/supabase-storage:
dependencies:
'@supabase/storage-js':
@@ -905,6 +948,12 @@ importers:
'@tanstack/react-form':
specifier: ^1.33.5
version: 1.33.5(@tanstack/react-start@1.168.60(crossws@0.4.12(srvx@1.0.5))(esbuild@0.28.2)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(rolldown@1.2.13)(rollup@4.63.5)(vite@8.3.4(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)))(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
+ '@tanstack/react-query':
+ specifier: ^5.104.0
+ version: 5.104.1(react@19.3.0)
+ '@tanstack/react-router':
+ specifier: ^1.170.41
+ version: 1.170.41(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
'@vitnode/core':
specifier: workspace:*
version: link:../../packages/vitnode
@@ -1262,6 +1311,9 @@ packages:
resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==}
engines: {node: '>=18'}
+ '@borewit/text-codec@0.2.2':
+ resolution: {integrity: sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==}
+
'@bramus/specificity@2.4.3':
resolution: {integrity: sha512-0GEz8qJWokkUVYQYRukmkSHIFNoBH8QTreeHrAFbvz1XFYc3Y+puhMs8PQ5x8GNugiC2l5jvDBXKLbBIByeSBw==}
hasBin: true
@@ -2290,6 +2342,119 @@ packages:
os: [linux]
libc: [glibc]
+ '@napi-rs/nice-android-arm-eabi@1.1.1':
+ resolution: {integrity: sha512-kjirL3N6TnRPv5iuHw36wnucNqXAO46dzK9oPb0wj076R5Xm8PfUVA9nAFB5ZNMmfJQJVKACAPd/Z2KYMppthw==}
+ engines: {node: '>= 10'}
+ cpu: [arm]
+ os: [android]
+
+ '@napi-rs/nice-android-arm64@1.1.1':
+ resolution: {integrity: sha512-blG0i7dXgbInN5urONoUCNf+DUEAavRffrO7fZSeoRMJc5qD+BJeNcpr54msPF6qfDD6kzs9AQJogZvT2KD5nw==}
+ engines: {node: '>= 10'}
+ cpu: [arm64]
+ os: [android]
+
+ '@napi-rs/nice-darwin-arm64@1.1.1':
+ resolution: {integrity: sha512-s/E7w45NaLqTGuOjC2p96pct4jRfo61xb9bU1unM/MJ/RFkKlJyJDx7OJI/O0ll/hrfpqKopuAFDV8yo0hfT7A==}
+ engines: {node: '>= 10'}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@napi-rs/nice-darwin-x64@1.1.1':
+ resolution: {integrity: sha512-dGoEBnVpsdcC+oHHmW1LRK5eiyzLwdgNQq3BmZIav+9/5WTZwBYX7r5ZkQC07Nxd3KHOCkgbHSh4wPkH1N1LiQ==}
+ engines: {node: '>= 10'}
+ cpu: [x64]
+ os: [darwin]
+
+ '@napi-rs/nice-freebsd-x64@1.1.1':
+ resolution: {integrity: sha512-kHv4kEHAylMYmlNwcQcDtXjklYp4FCf0b05E+0h6nDHsZ+F0bDe04U/tXNOqrx5CmIAth4vwfkjjUmp4c4JktQ==}
+ engines: {node: '>= 10'}
+ cpu: [x64]
+ os: [freebsd]
+
+ '@napi-rs/nice-linux-arm-gnueabihf@1.1.1':
+ resolution: {integrity: sha512-E1t7K0efyKXZDoZg1LzCOLxgolxV58HCkaEkEvIYQx12ht2pa8hoBo+4OB3qh7e+QiBlp1SRf+voWUZFxyhyqg==}
+ engines: {node: '>= 10'}
+ cpu: [arm]
+ os: [linux]
+
+ '@napi-rs/nice-linux-arm64-gnu@1.1.1':
+ resolution: {integrity: sha512-CIKLA12DTIZlmTaaKhQP88R3Xao+gyJxNWEn04wZwC2wmRapNnxCUZkVwggInMJvtVElA+D4ZzOU5sX4jV+SmQ==}
+ engines: {node: '>= 10'}
+ cpu: [arm64]
+ os: [linux]
+ libc: [glibc]
+
+ '@napi-rs/nice-linux-arm64-musl@1.1.1':
+ resolution: {integrity: sha512-+2Rzdb3nTIYZ0YJF43qf2twhqOCkiSrHx2Pg6DJaCPYhhaxbLcdlV8hCRMHghQ+EtZQWGNcS2xF4KxBhSGeutg==}
+ engines: {node: '>= 10'}
+ cpu: [arm64]
+ os: [linux]
+ libc: [musl]
+
+ '@napi-rs/nice-linux-ppc64-gnu@1.1.1':
+ resolution: {integrity: sha512-4FS8oc0GeHpwvv4tKciKkw3Y4jKsL7FRhaOeiPei0X9T4Jd619wHNe4xCLmN2EMgZoeGg+Q7GY7BsvwKpL22Tg==}
+ engines: {node: '>= 10'}
+ cpu: [ppc64]
+ os: [linux]
+ libc: [glibc]
+
+ '@napi-rs/nice-linux-riscv64-gnu@1.1.1':
+ resolution: {integrity: sha512-HU0nw9uD4FO/oGCCk409tCi5IzIZpH2agE6nN4fqpwVlCn5BOq0MS1dXGjXaG17JaAvrlpV5ZeyZwSon10XOXw==}
+ engines: {node: '>= 10'}
+ cpu: [riscv64]
+ os: [linux]
+ libc: [glibc]
+
+ '@napi-rs/nice-linux-s390x-gnu@1.1.1':
+ resolution: {integrity: sha512-2YqKJWWl24EwrX0DzCQgPLKQBxYDdBxOHot1KWEq7aY2uYeX+Uvtv4I8xFVVygJDgf6/92h9N3Y43WPx8+PAgQ==}
+ engines: {node: '>= 10'}
+ cpu: [s390x]
+ os: [linux]
+ libc: [glibc]
+
+ '@napi-rs/nice-linux-x64-gnu@1.1.1':
+ resolution: {integrity: sha512-/gaNz3R92t+dcrfCw/96pDopcmec7oCcAQ3l/M+Zxr82KT4DljD37CpgrnXV+pJC263JkW572pdbP3hP+KjcIg==}
+ engines: {node: '>= 10'}
+ cpu: [x64]
+ os: [linux]
+ libc: [glibc]
+
+ '@napi-rs/nice-linux-x64-musl@1.1.1':
+ resolution: {integrity: sha512-xScCGnyj/oppsNPMnevsBe3pvNaoK7FGvMjT35riz9YdhB2WtTG47ZlbxtOLpjeO9SqqQ2J2igCmz6IJOD5JYw==}
+ engines: {node: '>= 10'}
+ cpu: [x64]
+ os: [linux]
+ libc: [musl]
+
+ '@napi-rs/nice-openharmony-arm64@1.1.1':
+ resolution: {integrity: sha512-6uJPRVwVCLDeoOaNyeiW0gp2kFIM4r7PL2MczdZQHkFi9gVlgm+Vn+V6nTWRcu856mJ2WjYJiumEajfSm7arPQ==}
+ engines: {node: '>= 10'}
+ cpu: [arm64]
+ os: [openharmony]
+
+ '@napi-rs/nice-win32-arm64-msvc@1.1.1':
+ resolution: {integrity: sha512-uoTb4eAvM5B2aj/z8j+Nv8OttPf2m+HVx3UjA5jcFxASvNhQriyCQF1OB1lHL43ZhW+VwZlgvjmP5qF3+59atA==}
+ engines: {node: '>= 10'}
+ cpu: [arm64]
+ os: [win32]
+
+ '@napi-rs/nice-win32-ia32-msvc@1.1.1':
+ resolution: {integrity: sha512-CNQqlQT9MwuCsg1Vd/oKXiuH+TcsSPJmlAFc5frFyX/KkOh0UpBLEj7aoY656d5UKZQMQFP7vJNa1DNUNORvug==}
+ engines: {node: '>= 10'}
+ cpu: [ia32]
+ os: [win32]
+
+ '@napi-rs/nice-win32-x64-msvc@1.1.1':
+ resolution: {integrity: sha512-vB+4G/jBQCAh0jelMTY3+kgFy00Hlx2f2/1zjMoH821IbplbWZOkLiTYXQkygNTzQJTq5cvwBDgn2ppHD+bglQ==}
+ engines: {node: '>= 10'}
+ cpu: [x64]
+ os: [win32]
+
+ '@napi-rs/nice@1.1.1':
+ resolution: {integrity: sha512-xJIPs+bYuc9ASBl+cvGsKbGrJmS6fAKaSZCnT0lhahT5rhA2VVy9/EcIgd2JhtEuFOJNx7UHNn/qiTPTY4nrQw==}
+ engines: {node: '>= 10'}
+
'@napi-rs/wasm-runtime@1.2.4':
resolution: {integrity: sha512-AJxoUD2/15ESHbvpcyjU274nsAPLuOtPHCk0vKJM5pj//Fg/B1FXNWjPnXTT9PymCYYiHo4zPj0ZomXBKhoy7g==}
engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0}
@@ -3497,72 +3662,36 @@ packages:
cpu: [arm]
os: [android]
- '@rolldown/binding-android-arm-eabi@1.2.13':
- resolution: {integrity: sha512-Vu/NICrgdRvUOeaJZIyB2FP63s6fO/4ud5l8dXmxDx9yDyn+mN0y+gsvsVviSli+T361Aqo0h46EuMoobrwawA==}
- engines: {node: ^20.19.0 || >=22.12.0}
- cpu: [arm]
- os: [android]
-
'@rolldown/binding-android-arm64@1.2.12':
resolution: {integrity: sha512-7KHFgQ5VJxIHcLlrwrc3Xbds7oTNQT7Pgi9gQCJKrd2VGab/UksIOYp6VD8MzCstGxOKMgNamPwUCfxPdP1OHg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [android]
- '@rolldown/binding-android-arm64@1.2.13':
- resolution: {integrity: sha512-6LEoIrGy5vvCd2boNnv2soPSUZj/PRvhp8R/t9KlX+mIZxThIE/ZqxrGXxmuNX0FuvQFvLdZr7S4HrYpYBVhEQ==}
- engines: {node: ^20.19.0 || >=22.12.0}
- cpu: [arm64]
- os: [android]
-
'@rolldown/binding-darwin-arm64@1.2.12':
resolution: {integrity: sha512-3YIhqHD96nA5SaYNRBR16HnGv4oavZvXfD/ayHM+oYZ0WD/8lBAtf6zQua4kEyAvpqrluKXl0lnOBoiNby7x9w==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [darwin]
- '@rolldown/binding-darwin-arm64@1.2.13':
- resolution: {integrity: sha512-CaJSRzlQ6Q+zkDMVA9OkVxBjVoPRCDEE3v0FSJ9kIQBgRjFApA3Si4ijhbNVUji7ZJt+PfO0e46qy4nPInHUPA==}
- engines: {node: ^20.19.0 || >=22.12.0}
- cpu: [arm64]
- os: [darwin]
-
'@rolldown/binding-darwin-x64@1.2.12':
resolution: {integrity: sha512-UuuJ35MFw4gmFOrE9pEqIV+K3syIKveph+Qc1/ljHZVdoDW4pz/JHR/eMVom+TZGl/5OOvGJOWaOCVt3ZfqhxA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [darwin]
- '@rolldown/binding-darwin-x64@1.2.13':
- resolution: {integrity: sha512-e4r340I5hxSzJkD3YJBxgzq/rJ3wwxwdLLb2FPt1Zpyem20nrQPT11DDEEkazwxaHumH8wpglsp/Ozgj/wT6mw==}
- engines: {node: ^20.19.0 || >=22.12.0}
- cpu: [x64]
- os: [darwin]
-
'@rolldown/binding-freebsd-x64@1.2.12':
resolution: {integrity: sha512-uMvssit0a4W+/7D8CbHUvG719mH3R2jwXAlh/XcPvuHTE0g++LymF88DCGNX0HM2rBOn0xrzgXktIB6fLSJBTQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [freebsd]
- '@rolldown/binding-freebsd-x64@1.2.13':
- resolution: {integrity: sha512-hoBWDZ4/K/mJKVMbsgUd1jfpA2DiOrJ54mpegObU9TBHBBrtX4FLIXJJQRDPZ/2Dn91GvPob+Z2hwKJzf4E5SQ==}
- engines: {node: ^20.19.0 || >=22.12.0}
- cpu: [x64]
- os: [freebsd]
-
'@rolldown/binding-linux-arm-gnueabihf@1.2.12':
resolution: {integrity: sha512-XcFu0R0xWnwzSf4IQgFH1rJIckPN1pLy2R+4r9IDB7Yfu/ys9cVqfa4pBrMHj7a3gl8mIR4nRNPg0e5IvEVs6g==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [linux]
- '@rolldown/binding-linux-arm-gnueabihf@1.2.13':
- resolution: {integrity: sha512-oF9fZu3vTWnTiVAkDFxU9PJLt1nh20K9RRpuHsR7ZnE2L7XookBbDBVLojCVSLGbvtSlxeUNnjAj6DbNsKyzbg==}
- engines: {node: ^20.19.0 || >=22.12.0}
- cpu: [arm]
- os: [linux]
-
'@rolldown/binding-linux-arm64-gnu@1.2.12':
resolution: {integrity: sha512-260UrKgn8tz39ak+SMDOirKzr7V04M9dWPw5llW00SwBivCZoWcRBKV1d8cXnRkUmSZA3BdiUmBHWk7734Ulpw==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -3570,13 +3699,6 @@ packages:
os: [linux]
libc: [glibc]
- '@rolldown/binding-linux-arm64-gnu@1.2.13':
- resolution: {integrity: sha512-DTpb/+jMiqWMPgphn4ksW1zW+XLJ6dJJ9FMvnIk9I291LhCctMPhMYJrP5t9p6C9qa3KXadDdUEZHBaPh3kNxQ==}
- engines: {node: ^20.19.0 || >=22.12.0}
- cpu: [arm64]
- os: [linux]
- libc: [glibc]
-
'@rolldown/binding-linux-arm64-musl@1.2.12':
resolution: {integrity: sha512-5YK1I9SqDkbPgc1IA8BgDl34suqUS2q0KWnBrirm0E51YjOs6eo6dV6jbQfNE/argHRSvd0QUGgtpIoYx+WWpw==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -3584,13 +3706,6 @@ packages:
os: [linux]
libc: [musl]
- '@rolldown/binding-linux-arm64-musl@1.2.13':
- resolution: {integrity: sha512-xU+Kvv9N2A3SY35zFh1FWWNjxbadE/BKeqnkXe7vScU4GZsw757BlVmDLITZEq8DPpk6m7uoDYkK7P+x/uU5Ow==}
- engines: {node: ^20.19.0 || >=22.12.0}
- cpu: [arm64]
- os: [linux]
- libc: [musl]
-
'@rolldown/binding-linux-ppc64-gnu@1.2.12':
resolution: {integrity: sha512-Rkcrmp7eFRg74yL5fXEU91JEWbdEPLevWwGtXpmhbjlD1StScbWTmO94Bhly+Mo+ketKYkdmM1vNUKeWSlx8cQ==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -3598,13 +3713,6 @@ packages:
os: [linux]
libc: [glibc]
- '@rolldown/binding-linux-ppc64-gnu@1.2.13':
- resolution: {integrity: sha512-XMk/hnhh25oQ756Lclb1Q11h4ll0xC0Esa5w4Vzp+mAE2S3AGJgeM0SonrIgY8fHhjarb6hcoTuwoFG0VVP2ig==}
- engines: {node: ^20.19.0 || >=22.12.0}
- cpu: [ppc64]
- os: [linux]
- libc: [glibc]
-
'@rolldown/binding-linux-s390x-gnu@1.2.12':
resolution: {integrity: sha512-qvK4DuAsQc2BSjlx+Xr+IzOIvvxbGZqxFwdWfG6F518Erj0GGISyQbJ6pIappnOxlNPzNHvo/L0BwB30GZ+zVw==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -3612,13 +3720,6 @@ packages:
os: [linux]
libc: [glibc]
- '@rolldown/binding-linux-s390x-gnu@1.2.13':
- resolution: {integrity: sha512-U1BoTHYmlywE2n3HUWjzZ+8EQsKr+OwpxYuDWaubx42tUL8QSL7nOqRz/NVHanibbFP3M1Rxuprz/wNcbMKjqw==}
- engines: {node: ^20.19.0 || >=22.12.0}
- cpu: [s390x]
- os: [linux]
- libc: [glibc]
-
'@rolldown/binding-linux-x64-gnu@1.2.12':
resolution: {integrity: sha512-Q9uLBO53Xd4QIq1WOycVQyPP1O4HhraEV2qqb3uTrnVw6QZih9duY4vNXOivL1xoUS1/z+W8eF4NMfl2a8Sdjw==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -3626,13 +3727,6 @@ packages:
os: [linux]
libc: [glibc]
- '@rolldown/binding-linux-x64-gnu@1.2.13':
- resolution: {integrity: sha512-J4cm80Pc6wEwV5AZ42o62sIQgDk+Y7R2s646jIwuvO+4V46QB58N4vVuMPWCBaNgursXLpmUWJV//WQAkPvHBQ==}
- engines: {node: ^20.19.0 || >=22.12.0}
- cpu: [x64]
- os: [linux]
- libc: [glibc]
-
'@rolldown/binding-linux-x64-musl@1.2.12':
resolution: {integrity: sha512-3IBxWFMjbOZskDPKv8Lf9BCnahlKuHthWkYnyIxOH/QcJrFcS4EmcenthApkwr/5+nEqZlLzeYbxeMaX7A5u4g==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -3640,49 +3734,24 @@ packages:
os: [linux]
libc: [musl]
- '@rolldown/binding-linux-x64-musl@1.2.13':
- resolution: {integrity: sha512-W0euZS40U4V6JkmEnVCIX9mcsL6cbXQ4X8c2w+MMOIjFg68TXUW+u3RT+2e1t1pv9mg/EP3pDtK3POXdQ7Rxyw==}
- engines: {node: ^20.19.0 || >=22.12.0}
- cpu: [x64]
- os: [linux]
- libc: [musl]
-
'@rolldown/binding-openharmony-arm64@1.2.12':
resolution: {integrity: sha512-xtX61xg4LKPkPWilZU1ynKClz5Gj4bf74LML4r3eVLWumKnGjoEr1OSHQhMdbBDoYTi+yjrujvpZe2pUnqCrrA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [openharmony]
- '@rolldown/binding-openharmony-arm64@1.2.13':
- resolution: {integrity: sha512-do/AF/4n9MRshia3a5aXoy8xM2c6mgfT6D7H/dY6my5z+hfCIR4ab7mk4IRj++nsEQpKy39kd0DQnDq+r692tw==}
- engines: {node: ^20.19.0 || >=22.12.0}
- cpu: [arm64]
- os: [openharmony]
-
'@rolldown/binding-win32-arm64-msvc@1.2.12':
resolution: {integrity: sha512-At7fPB6PCaIjzgIhEZFxuT+BBFqiQibJDT4d3PhiR3f4E7bbMZF4aKblbFfEM3sETRDd1YiQx/+U/g/B/ou5Ew==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [win32]
- '@rolldown/binding-win32-arm64-msvc@1.2.13':
- resolution: {integrity: sha512-6FtUf2cJZobutJdxnGYX8z0vtbgqU0b2v8vfBhkXb9wp2MppMFtkunE+IaOntyi01rkIQoPW2GHABtfzsC0BRQ==}
- engines: {node: ^20.19.0 || >=22.12.0}
- cpu: [arm64]
- os: [win32]
-
'@rolldown/binding-win32-x64-msvc@1.2.12':
resolution: {integrity: sha512-WIw2haVKwjuYdXkHaoC0mF8Le71TuCBxjrdKqLbJGctbBABj+ClfmNvtbOnzpq3RokNo5+V1qhtSzJyXorsklQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [win32]
- '@rolldown/binding-win32-x64-msvc@1.2.13':
- resolution: {integrity: sha512-UKhFaZTeTcbKrb3iU82m17jVcPEAoqDkCN5MBqgPyZSwp/cQmib21crTS0DdH7T1B7wZRWEI89YQAOJMet5yhg==}
- engines: {node: ^20.19.0 || >=22.12.0}
- cpu: [x64]
- os: [win32]
-
'@rolldown/pluginutils@1.0.1':
resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==}
@@ -3937,6 +4006,10 @@ packages:
resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==}
engines: {node: '>=10'}
+ '@sindresorhus/is@7.2.0':
+ resolution: {integrity: sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==}
+ engines: {node: '>=18'}
+
'@sindresorhus/merge-streams@4.0.0':
resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==}
engines: {node: '>=18'}
@@ -4048,9 +4121,113 @@ packages:
resolution: {integrity: sha512-QhUxFC33Crlw6WDeA4hLADdi1PzjI3c4UJs7ivWHHEnU7ulmCWEZSDaRCuIfmsjUwbHYaIfRNFQNsm+IsGYS6w==}
engines: {node: '>=22.0.0'}
+ '@swc/cli@0.8.1':
+ resolution: {integrity: sha512-L+ACCGHCiS0VqHVep/INLVnvRvJ2XooQFLZq4L8snhxw1jsqz+XRcY313UsyPVturPPE1shW3jic7rt3qEQTSQ==}
+ engines: {node: '>= 20.19.0'}
+ hasBin: true
+ peerDependencies:
+ '@swc/core': ^1.2.66
+ chokidar: ^5.0.0
+ peerDependenciesMeta:
+ chokidar:
+ optional: true
+
+ '@swc/core-darwin-arm64@1.16.13':
+ resolution: {integrity: sha512-JrMH2h7ohd4mOIneNRx/diqTmCDSdWfp9uOdQ8VJ2V2CA7jOJ1jWb+qp8RoZLqgiVmwDDC7eySKB1kbvZ+jVXQ==}
+ engines: {node: '>=10'}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@swc/core-darwin-x64@1.16.13':
+ resolution: {integrity: sha512-HauZMRxpOImRxgY6GXjw5gbD8ts2NcPZuLMM8htY8Mic7j1Izt/lxkXrKYUwqyU56YLwQZ4dHRT+6jFHkztcag==}
+ engines: {node: '>=10'}
+ cpu: [x64]
+ os: [darwin]
+
+ '@swc/core-linux-arm-gnueabihf@1.16.13':
+ resolution: {integrity: sha512-tUSojGIythnHJKrElDM9OvuhBnK+FF39eM9iIBgj9kdfbAm6PhAM7dKbaSDcnX2gOLG2slkasjD0Z8m/YUlzOA==}
+ engines: {node: '>=10'}
+ cpu: [arm]
+ os: [linux]
+
+ '@swc/core-linux-arm64-gnu@1.16.13':
+ resolution: {integrity: sha512-qS3bk/ezRrLXGn1b764S/EG3/oKj6uRhnTNRAALpnuu8d9I0YwSiUkQghnX7lOH9X/yRr+qrFI3R0wAruO47Tg==}
+ engines: {node: '>=10'}
+ cpu: [arm64]
+ os: [linux]
+ libc: [glibc]
+
+ '@swc/core-linux-arm64-musl@1.16.13':
+ resolution: {integrity: sha512-aeoYoG6fQsOS3mFOuTKvnJo1aaaxYNI0aRRlhdHaGP4q1YQx4XOc5Hkd35IFduWCBG3eZl3fPTjRuYvxpl7eTA==}
+ engines: {node: '>=10'}
+ cpu: [arm64]
+ os: [linux]
+ libc: [musl]
+
+ '@swc/core-linux-ppc64-gnu@1.16.13':
+ resolution: {integrity: sha512-eEFoueZXWZ2S1lsqL8Ve2C8RjhzH9+Ktj8U4IGTUQL6CNG04+VhQiYQWJOf5xfVBU6I+PbH80yvsYegqZOOSZQ==}
+ engines: {node: '>=10'}
+ cpu: [ppc64]
+ os: [linux]
+ libc: [glibc]
+
+ '@swc/core-linux-s390x-gnu@1.16.13':
+ resolution: {integrity: sha512-K0q1ReVXL3d5bzBgZxS+yJq5q2xRjg4k2XXGj9yiCEpE5ZqJGQ4Gtk1sRrk8W2lo3KY0DCJD3Myt6x2SMvGArQ==}
+ engines: {node: '>=10'}
+ cpu: [s390x]
+ os: [linux]
+ libc: [glibc]
+
+ '@swc/core-linux-x64-gnu@1.16.13':
+ resolution: {integrity: sha512-Qvpihw30irhIjy2f91BR1pamcJBCgf8hmr9XWenztAX1YLkneRkkTB/PFGWQubhaBqQhY2uZKViMj/xV61uMBQ==}
+ engines: {node: '>=10'}
+ cpu: [x64]
+ os: [linux]
+ libc: [glibc]
+
+ '@swc/core-linux-x64-musl@1.16.13':
+ resolution: {integrity: sha512-iCGxHbh3Cq5vqRvCPlw3XkkDz4RlkPSyQwfFKbOKTMj5tiEuojY/rLmf4BMfI9Z8yPJTf9VB07t3NbKInoC97g==}
+ engines: {node: '>=10'}
+ cpu: [x64]
+ os: [linux]
+ libc: [musl]
+
+ '@swc/core-win32-arm64-msvc@1.16.13':
+ resolution: {integrity: sha512-iOabn6cd+hg3cnoKlAC8j3Yufv8nQHBVU5OIDX8dCPA95PjAZ0B0TlDn9sWivk8ezo/cLJux7p9n0B9vRej+1Q==}
+ engines: {node: '>=10'}
+ cpu: [arm64]
+ os: [win32]
+
+ '@swc/core-win32-ia32-msvc@1.16.13':
+ resolution: {integrity: sha512-+1b/3xBNuVbpnJIZUCAaSdscOPYRWq85eohKx44y/3qbPp56UMiCJWBiWtyr7K5F1nf1JENXm2IBSEJaLgWq6Q==}
+ engines: {node: '>=10'}
+ cpu: [ia32]
+ os: [win32]
+
+ '@swc/core-win32-x64-msvc@1.16.13':
+ resolution: {integrity: sha512-6kEJhYshk7OVoVIWryiHqjCNuwZeOlPTS6et+bKV/WhtHs8fLA01wyTLrQGi+4ultgRe/o0M4b25K/KKdvl0dQ==}
+ engines: {node: '>=10'}
+ cpu: [x64]
+ os: [win32]
+
+ '@swc/core@1.16.13':
+ resolution: {integrity: sha512-k4uUsIMS0IwOKVsrQs48IZK75O4roV3FnuJEdL7ES+DFESftdgvz73QBqN3VUAbUmUr78We4tzG0kgYj36M22Q==}
+ engines: {node: '>=10'}
+ peerDependencies:
+ '@swc/helpers': '>=0.5.17'
+ peerDependenciesMeta:
+ '@swc/helpers':
+ optional: true
+
+ '@swc/counter@0.1.3':
+ resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==}
+
'@swc/helpers@0.5.23':
resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==}
+ '@swc/types@0.1.28':
+ resolution: {integrity: sha512-V6Mnml8v09QALx6K0elJ7o9K/MkVDtW3t6L+7Ou/JcWtb3xwId2AH4FeOceySd2JaO87IMw4+6vSZxLm34LPbw==}
+
'@tailwindcss/node@4.3.3':
resolution: {integrity: sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==}
@@ -4722,6 +4899,13 @@ packages:
y-protocols: ^1.0.1
yjs: ^13.5.38
+ '@tokenizer/inflate@0.4.1':
+ resolution: {integrity: sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==}
+ engines: {node: '>=18'}
+
+ '@tokenizer/token@0.3.0':
+ resolution: {integrity: sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==}
+
'@ts-morph/common@0.27.0':
resolution: {integrity: sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ==}
@@ -4812,6 +4996,9 @@ packages:
'@types/hast@3.0.5':
resolution: {integrity: sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==}
+ '@types/http-cache-semantics@4.2.0':
+ resolution: {integrity: sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==}
+
'@types/json-schema@7.0.15':
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
@@ -4996,6 +5183,46 @@ packages:
'@workflow/serde@4.1.0':
resolution: {integrity: sha512-pav4F2BoirECWR7Nf1TKt+2eETcBj7jj4cBefQ8VXQCA6NPkaKeLfj/zMgi+3zYV5ZIBT4GuUiphsj0/b9hPQQ==}
+ '@xhmikosr/archive-type@8.1.0':
+ resolution: {integrity: sha512-EXOjEbnZFE5c/nFMf4FOrEURVanzHpnkPYmnmr78u02/8hAhE0FMq8p9TK1IM0/bFr5VcyBUY0gfLm8f7dKy+Q==}
+ engines: {node: '>=20'}
+
+ '@xhmikosr/bin-check@8.3.0':
+ resolution: {integrity: sha512-ALFOzArQWaPhSb6zr5yQslFr/vyc/ZJENLzFeFzqeTWI2exm3V84zMieU2FEI29KBuotkX1zf5RTKXIc0UaChQ==}
+ engines: {node: '>=20'}
+
+ '@xhmikosr/bin-wrapper@14.5.1':
+ resolution: {integrity: sha512-UZUuTYWxeAbTIiRKKEAmV3csoE36B3CGFZrYYn87+bSEBTyJ32p5gx5Gmj5HOgyOtioFUypbOZ1V5M/l/VoePw==}
+ engines: {node: '>=20'}
+
+ '@xhmikosr/decompress-tar@9.0.2':
+ resolution: {integrity: sha512-8nPZ6lZ3ExhsSxi/X/PMB3K+Vtsuxk43HowxYpxw4AsCHTYqFBXwC8B3Y+M/meaUOGOVm+2tFNUAfWGRjBt+Ww==}
+ engines: {node: '>=20'}
+
+ '@xhmikosr/decompress-tarbz2@9.0.2':
+ resolution: {integrity: sha512-m0DvZhE7remCxtS8xY2iHSjivT4v+iyYDdfNoeuu8Nm+7g8xEXdLKSyDEicu4u1ImJLLGEfjMuTLera/F6UGWw==}
+ engines: {node: '>=20'}
+
+ '@xhmikosr/decompress-targz@9.0.1':
+ resolution: {integrity: sha512-1JXu2b6yrpm5EuBoOzMU57B4qrHXJKWQQ7LlMynNEiz85mEjDciO3ayf//GXaTLLCEKiHjWlU3q3THjgf7uODA==}
+ engines: {node: '>=20'}
+
+ '@xhmikosr/decompress-unzip@8.2.1':
+ resolution: {integrity: sha512-2MS94QnmXQwjkKN8WyFiu1sU7J3rcWJcMze4kRYsX7tN+CXpUGECgkh4YSOhujpkWPuVlFudIziJHO/TxOqkQQ==}
+ engines: {node: '>=20'}
+
+ '@xhmikosr/decompress@11.1.4':
+ resolution: {integrity: sha512-ZbYL7SAfY37/TMpopqBR3mQiuQ76kI/RNpN4q82YHSw/UxktZNy8P4wgiKPSrTImMAWRaUG8UK1pgEa56YdLaw==}
+ engines: {node: '>=20'}
+
+ '@xhmikosr/downloader@16.3.1':
+ resolution: {integrity: sha512-M67dvznaFbsvoqhGT4FsHWysaXXQ8386OViGZm0WOyQS3apW9p16WgvHp9nWj2vfKQAR2ZdqIBPNCHSM5rKSCg==}
+ engines: {node: '>=20'}
+
+ '@xhmikosr/os-filter-obj@4.1.0':
+ resolution: {integrity: sha512-y5ArHvQ7BVule/+L9yE2nYMhceiJhgsqo58lOfnisQ7bg+Kjfmkgr7JBuVFiTkl+ErdShpp829QstZQyLugl8g==}
+ engines: {node: '>=20'}
+
'@yuku-analyzer/binding-android-arm64@0.10.2':
resolution: {integrity: sha512-xt4Vy3RTBw1NfszBlf4A0Rr6ZjA/th2Pj9XlWFLQHW9A1qOCtxNCoIWSzn/INtnZLMB4jO2gWdEtSaHUtK73wA==}
cpu: [arm64]
@@ -5359,6 +5586,14 @@ packages:
atomically@2.1.1:
resolution: {integrity: sha512-P4w9o2dqARji6P7MHprklbfiArZAWvo07yW7qs3pdljb3BWr12FIB7W+p0zJiuiVsUpRO0iZn1kFFcpPegg0tQ==}
+ b4a@1.9.0:
+ resolution: {integrity: sha512-dpfcF9fDNR6++cthXR67iyhgqWy9CBouAvIWhIntzBG6cvK/cnIPiZQjBwi/ZqjjBEDGfoNDtmB0kTjroOJ3pQ==}
+ peerDependencies:
+ react-native-b4a: '*'
+ peerDependenciesMeta:
+ react-native-b4a:
+ optional: true
+
babel-dead-code-elimination@1.0.12:
resolution: {integrity: sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig==}
@@ -5368,10 +5603,24 @@ packages:
bail@2.0.2:
resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==}
+ balanced-match@1.0.2:
+ resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
+
balanced-match@4.0.4:
resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}
engines: {node: 18 || 20 || >=22}
+ bare-events@2.9.2:
+ resolution: {integrity: sha512-AIPKioV7/Y/8KfZ3AAhjPJxLLbY49S64Ym5DakZlUg75qQiTgUq9hEJoEwa4eUezPUlXRy/i5NpsKvo9jgKmoA==}
+ peerDependencies:
+ bare-abort-controller: '*'
+ peerDependenciesMeta:
+ bare-abort-controller:
+ optional: true
+
+ base64-js@1.5.1:
+ resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
+
baseline-browser-mapping@2.11.27:
resolution: {integrity: sha512-ElY12DaROGuan+lMmZ8Cvo/ZUbXPe7Enc/9VU/b1T3Kp4dwytRcNdR8DoSJN5SNJT/CuvcCA0DHDVmMOCePdRQ==}
engines: {node: '>=6.0.0'}
@@ -5389,6 +5638,14 @@ packages:
resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==}
engines: {node: '>=8'}
+ binary-version-check@6.1.0:
+ resolution: {integrity: sha512-REKdLKmuViV2WrtWXvNSiPX04KbIjfUV3Cy8batUeOg+FtmowavzJorfFhWq95cVJzINnL/44ixP26TrdJZACA==}
+ engines: {node: '>=18'}
+
+ binary-version@7.1.0:
+ resolution: {integrity: sha512-Iy//vPc3ANPNlIWd242Npqc8MK0a/i4kVcHDlDA6HNMv5zMxz4ulIFhOSYJVKw/8AbHdHy0CnGYEt1QqSXxPsw==}
+ engines: {node: '>=18'}
+
bippy@0.5.43:
resolution: {integrity: sha512-Tvu7b1M7+d8b9/YHaCeODEsi2CgbuoBql+dWSBrNnCuqJ1gMUeY3i0r+319hvjjl5GVBP6FFWxrKnq3fhZER0w==}
peerDependencies:
@@ -5406,6 +5663,9 @@ packages:
bowser@2.14.1:
resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==}
+ brace-expansion@2.1.7:
+ resolution: {integrity: sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==}
+
brace-expansion@5.0.12:
resolution: {integrity: sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==}
engines: {node: 20 || >=22}
@@ -5423,10 +5683,17 @@ packages:
resolution: {integrity: sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==}
engines: {node: '>=4.0'}
+ buffer@5.7.1:
+ resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==}
+
bundle-name@4.1.1:
resolution: {integrity: sha512-DdH81/zPLVS11EUgWq3tEu/xn+EzljlMYooDNdzWEnFha3R3NBMpMV1UqYIpjYHV/SgpFKMIX1Oh7o06SjM/oA==}
engines: {node: '>=18'}
+ byte-counter@0.1.0:
+ resolution: {integrity: sha512-jheRLVMeUKrDBjVw2O5+k4EvR4t9wtxHL+bo/LxfkxsVeuGMy3a5SEGgXdAFA4FSzTrU8rQXQIrsZ3oBq5a0pQ==}
+ engines: {node: '>=20'}
+
bytes@3.1.2:
resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==}
engines: {node: '>= 0.8'}
@@ -5435,6 +5702,14 @@ packages:
resolution: {integrity: sha512-86diuUyeeWM8CrqhpGCaDafrzjS1s++7sWy2AbAPoFNz/xKv7ID57CR4pZsMuGCh60PtsnEx4z68b+Vgt07VPw==}
engines: {node: '>=20.19.0'}
+ cacheable-lookup@7.0.0:
+ resolution: {integrity: sha512-+qJyx4xiKra8mZrcwhjMRMUhD5NR1R8esPkzIYxX96JiecFoxAXFuz/GpR3+ev4PE1WamHip78wV0vcmPQtp8w==}
+ engines: {node: '>=14.16'}
+
+ cacheable-request@13.0.19:
+ resolution: {integrity: sha512-SVXGH037+Mo1aIMO5B2UcleR43FGjFdN+M8JObSyEoQ2Mn4CODRWx28gN5jiTF0n5ItsgtIZfyargMNs8GX4kg==}
+ engines: {node: '>=18'}
+
cacheable@2.5.0:
resolution: {integrity: sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g==}
@@ -5571,6 +5846,14 @@ packages:
resolution: {integrity: sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==}
engines: {node: '>=22.12.0'}
+ commander@6.2.1:
+ resolution: {integrity: sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==}
+ engines: {node: '>= 6'}
+
+ commander@8.3.0:
+ resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==}
+ engines: {node: '>= 12'}
+
commander@9.5.0:
resolution: {integrity: sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==}
engines: {node: ^12.20.0 || >=14}
@@ -5600,6 +5883,10 @@ packages:
resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==}
engines: {node: '>=18'}
+ content-disposition@2.0.1:
+ resolution: {integrity: sha512-e+H0ZXHSWYrENhQzw1LPuP4oF5MzVKmDU6d3hxlvaPEYLLg62MxtQNPRx4SYSuYJSBUgnQIG4HIN2tEtNv7Dog==}
+ engines: {node: '>=18'}
+
content-type@1.0.5:
resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==}
engines: {node: '>= 0.6'}
@@ -5608,6 +5895,10 @@ packages:
resolution: {integrity: sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==}
engines: {node: '>=18'}
+ convert-hrtime@5.0.0:
+ resolution: {integrity: sha512-lOETlkIeYSJWcbbcvjRKGxVMXJR+8+OQb/mTPbA4ObPMytYIsUbuOE0Jzy60hjARYszq1id0j8KgVhC+WGZVTg==}
+ engines: {node: '>=12'}
+
convert-source-map@2.0.0:
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
@@ -5807,6 +6098,10 @@ packages:
decode-named-character-reference@1.3.0:
resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==}
+ decompress-response@10.0.0:
+ resolution: {integrity: sha512-oj7KWToJuuxlPr7VV0vabvxEIiqNMo+q0NueIiL3XhtwC6FVOX7Hr1c0C4eD0bmf7Zr+S/dSf2xvkH3Ad6sU3Q==}
+ engines: {node: '>=20'}
+
dedent@1.7.2:
resolution: {integrity: sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==}
peerDependencies:
@@ -6375,6 +6670,9 @@ packages:
resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==}
engines: {node: '>= 0.6'}
+ events-universal@1.0.1:
+ resolution: {integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==}
+
eventsource-parser@3.1.1:
resolution: {integrity: sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==}
engines: {node: '>=18.0.0'}
@@ -6387,6 +6685,10 @@ packages:
resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==}
engines: {node: '>=10'}
+ execa@8.0.1:
+ resolution: {integrity: sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==}
+ engines: {node: '>=16.17'}
+
execa@9.6.1:
resolution: {integrity: sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==}
engines: {node: ^18.19.0 || >=20.5.0}
@@ -6408,6 +6710,14 @@ packages:
exsolve@1.1.1:
resolution: {integrity: sha512-9U/jZUgjnSGyntRr6y5Muu1MJcwFl6kPu7k8qLF0IMNfLqvw0NZ4nnVDq0RVoZ0RvCyumib4Ez3KYrVfilrw+g==}
+ ext-list@2.2.2:
+ resolution: {integrity: sha512-u+SQgsubraE6zItfVA0tBuCBhfU9ogSRnsvygI7wht9TS510oLkBRXBsqopeUG/GBOIQyKZO9wjTqIu/sf5zFA==}
+ engines: {node: '>=0.10.0'}
+
+ ext-name@5.0.0:
+ resolution: {integrity: sha512-yblEwXAbGv1VQDmow7s38W77hzAgJAO50ztBLMcUyUBfxv1HC+LGwtiEN+Co6LtlqT/5uwVOxsD4TNIilWhwdQ==}
+ engines: {node: '>=4'}
+
extend@3.0.2:
resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==}
@@ -6418,6 +6728,9 @@ packages:
resolution: {integrity: sha512-8unmvA0qrfpFWBYUate9BTGtQdKHWBYt+KdAIkag7Vvxe7wz8Q2nJg7fLMC0bJqe0bjP31kca7Ckaawrs4Istg==}
engines: {node: '>=6.0.0'}
+ fast-fifo@1.3.2:
+ resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==}
+
fast-glob@3.3.3:
resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==}
engines: {node: '>=8.6.0'}
@@ -6465,6 +6778,18 @@ packages:
file-entry-cache@11.1.5:
resolution: {integrity: sha512-+PFTHITI08JIGhnNpGNI8T8inUpgZfk3GNEqfT9R2zZV2iFXg3CvqzSl/uEhs7TSGujYRELEANyDvS8Fj7+S7Q==}
+ file-type@21.3.4:
+ resolution: {integrity: sha512-Ievi/yy8DS3ygGvT47PjSfdFoX+2isQueoYP1cntFW1JLYAuS4GD7NUPGg4zv2iZfV52uDyk5w5Z0TdpRS6Q1g==}
+ engines: {node: '>=20'}
+
+ filename-reserved-regex@4.1.0:
+ resolution: {integrity: sha512-pdgqcVlu789LoYmu0kz4pxSphL0pX/UVFFDJ/kTHk+1ZRc43UiokPZFsRV8OGxW49khh11rj54AhqczGIwLa+g==}
+ engines: {node: '>=20'}
+
+ filenamify@7.0.3:
+ resolution: {integrity: sha512-Pf0dwHrWs1GcIK15ps304fmxp2AOcfNR6vOIMvCPEy6ZztKYxzRQHlOuPVWo2HRjbCLHaRTY+kD8n3RbQ0A/fw==}
+ engines: {node: '>=20'}
+
fill-range@7.1.1:
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
engines: {node: '>=8'}
@@ -6481,6 +6806,10 @@ packages:
resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==}
engines: {node: '>=10'}
+ find-versions@6.0.0:
+ resolution: {integrity: sha512-2kCCtc+JvcZ86IGAz3Z2Y0A1baIz9fL31pH/0S1IqZr9Iwnjq8izfPtrCyQKO6TLMPELLsQMre7VDqeIKCsHkA==}
+ engines: {node: '>=18'}
+
flat-cache@6.1.23:
resolution: {integrity: sha512-f++BY9pTk+983xK1FLzlLpmM0i0z+jHmx3QESGkURMXujQZz1k5wzwX6hjnQ8goaD0B+sYnDK1yZ6MTyZfUaqA==}
@@ -6490,6 +6819,10 @@ packages:
flatted@3.4.4:
resolution: {integrity: sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==}
+ form-data-encoder@4.1.0:
+ resolution: {integrity: sha512-G6NsmEW15s0Uw9XnCg+33H3ViYRyiM0hMrMhhqQOR8NFc5GhYrI+6I3u7OTw7b91J2g8rtvMBZJDbcGb2YUniw==}
+ engines: {node: '>= 18'}
+
forwarded@0.2.0:
resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==}
engines: {node: '>= 0.6'}
@@ -6628,6 +6961,10 @@ packages:
function-bind@1.1.2:
resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
+ function-timeout@1.0.2:
+ resolution: {integrity: sha512-939eZS4gJ3htTHAldmyyuzlrD58P03fHG49v2JfFXbV6OhvZKRC9j2yAtdHw/zrp2zXHuv05zMIy40F0ge7spA==}
+ engines: {node: '>=18'}
+
fuzzysort@3.1.0:
resolution: {integrity: sha512-sR9BNCjBg6LNgwvxlBd0sBABvQitkLzoVY9MYYROQVX/FvfJ4Mai9LsGhDgd8qYdds0bY77VzYd5iuB+v5rwQQ==}
@@ -6663,6 +7000,10 @@ packages:
resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==}
engines: {node: '>=10'}
+ get-stream@8.0.1:
+ resolution: {integrity: sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==}
+ engines: {node: '>=16'}
+
get-stream@9.0.1:
resolution: {integrity: sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==}
engines: {node: '>=18'}
@@ -6706,6 +7047,10 @@ packages:
resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
engines: {node: '>= 0.4'}
+ got@14.6.6:
+ resolution: {integrity: sha512-QLV1qeYSo5l13mQzWgP/y0LbMr5Plr5fJilgAIwgnwseproEbtNym8xpLsDzeZ6MWXgNE6kdWGBjdh3zT/Qerg==}
+ engines: {node: '>=20'}
+
graceful-fs@4.2.11:
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
@@ -6813,10 +7158,17 @@ packages:
htmlparser2@8.0.2:
resolution: {integrity: sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==}
+ http-cache-semantics@4.3.0:
+ resolution: {integrity: sha512-M5t5LlJpS1UHMjvwRQVdFHvPISGeLAxNcrWuJkeGh0KxsqCHZ1O3NXZU/8x7cD0BDcGW8kapxMKTvwlqrNkHkA==}
+
http-errors@2.0.1:
resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==}
engines: {node: '>= 0.8'}
+ http2-wrapper@2.2.1:
+ resolution: {integrity: sha512-V5nVw1PAOgfI3Lmeaj2Exmeg7fenjhRUgz1lPSezy1CuhPYbgQtbQj4jZfEAEMlaL+vupsvhjqCyjzob0yxsmQ==}
+ engines: {node: '>=10.19.0'}
+
httpxy@0.5.5:
resolution: {integrity: sha512-uDjmnPyp1q4Sgzf3w+J/Fc6UqcCEj0x4Wjp7OqK5dGhNeDgpyrAmnS6ey8QWrX3SWDon2DMKf9sBa5X9+CVyMA==}
@@ -6824,6 +7176,10 @@ packages:
resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==}
engines: {node: '>=10.17.0'}
+ human-signals@5.0.0:
+ resolution: {integrity: sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==}
+ engines: {node: '>=16.17.0'}
+
human-signals@8.0.1:
resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==}
engines: {node: '>=18.18.0'}
@@ -6839,6 +7195,9 @@ packages:
icu-minify@4.14.9:
resolution: {integrity: sha512-PmO+fNnC3K3mfPKTnm3RrPERrhQZnjm1AlpyZQ0ShbXi8Y/kYZ3YOde8TEjCLz9k3P2DmzkBn8lykGrCAi1Ajw==}
+ ieee754@1.2.1:
+ resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
+
ignore@5.3.2:
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
engines: {node: '>= 4'}
@@ -6871,6 +7230,9 @@ packages:
react: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc
+ inspect-with-kind@1.0.5:
+ resolution: {integrity: sha512-MAQUJuIo7Xqk8EVNP+6d3CKq9c80hi4tjIbIAT6lmGW9W6WzlHiu9PS8uSuUYU+Do+j1baiFp3H25XEVxDIG2g==}
+
internmap@1.0.1:
resolution: {integrity: sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==}
@@ -6955,6 +7317,10 @@ packages:
resolution: {integrity: sha512-IlsXEHOjtKhpN8r/tRFj2nDyTmHvcfNeu/nrRIcXE17ROeatXchkojffa1SpdqW4cr/Fj6QkEf/Gn4zf6KKvEQ==}
engines: {node: '>=12'}
+ is-plain-obj@1.1.0:
+ resolution: {integrity: sha512-yvkRyxmFKEOQ4pNXCmJG5AEQNlXJS5LaONXo5/cLdTZdWvsZ1ioJEonLGAosKlMWE8lwUy/bJzMjcw8az73+Fg==}
+ engines: {node: '>=0.10.0'}
+
is-plain-obj@4.1.0:
resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==}
engines: {node: '>=12'}
@@ -6977,6 +7343,10 @@ packages:
resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==}
engines: {node: '>=8'}
+ is-stream@3.0.0:
+ resolution: {integrity: sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==}
+ engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
+
is-stream@4.0.1:
resolution: {integrity: sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==}
engines: {node: '>=18'}
@@ -7008,6 +7378,10 @@ packages:
resolution: {integrity: sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==}
engines: {node: '>=18'}
+ isexe@4.0.0:
+ resolution: {integrity: sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==}
+ engines: {node: '>=20'}
+
isomorphic.js@0.2.5:
resolution: {integrity: sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==}
@@ -7102,6 +7476,10 @@ packages:
keyv@5.6.0:
resolution: {integrity: sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==}
+ kind-of@6.0.3:
+ resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==}
+ engines: {node: '>=0.10.0'}
+
kleur@3.0.3:
resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==}
engines: {node: '>=6'}
@@ -7298,6 +7676,10 @@ packages:
longest-streak@3.1.0:
resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==}
+ lowercase-keys@3.0.0:
+ resolution: {integrity: sha512-ozCC6gdQ+glXOQsveKD0YsDy8DSQFjDTz4zyzEHNV5+JP5D62LmfDZ6o1cycFx9ouG940M5dE8C8CTewdj2YWQ==}
+ engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
+
lru-cache@11.5.3:
resolution: {integrity: sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==}
engines: {node: 20 || >=22}
@@ -7334,6 +7716,10 @@ packages:
magicast@0.5.5:
resolution: {integrity: sha512-UicdXN8zQ3JHlxVq+28afMXPr1z7WNY6+7EJnzTdQWkTAlMLF5fNCCKxJHBQwGaNGR11581EiQmQzx73+MvszA==}
+ make-asynchronous@1.1.0:
+ resolution: {integrity: sha512-ayF7iT+44LXdxJLTrTd3TLQpFDDvPCBxXxbv+pMUSuHA5Q8zyAfwkRP6aHHwNVFBUFWtxAHqwNJxF8vMZLAbVg==}
+ engines: {node: '>=18'}
+
markdown-extensions@2.0.0:
resolution: {integrity: sha512-o5vL7aDWatOTX8LzaS1WMoaoxIiLRQJuIKKe2wAw6IeULDHaqbiqiggmx+pKvZDb1Sj+pE46Sn1T7lCqfFtg1Q==}
engines: {node: '>=16'}
@@ -7552,14 +7938,26 @@ packages:
resolution: {integrity: sha512-Ysbi9uYW9hFyfrThdDEQuykN4Ey6BuwPD2kpI5ES/nFTDn/98yxYNLZJcgUAKPT/mcrLLKaGzJR9YVxJrIdASQ==}
engines: {node: '>=8'}
+ mimic-fn@4.0.0:
+ resolution: {integrity: sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==}
+ engines: {node: '>=12'}
+
mimic-function@5.0.1:
resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==}
engines: {node: '>=18'}
+ mimic-response@4.0.0:
+ resolution: {integrity: sha512-e5ISH9xMYU0DzrT+jl8q2ze9D6eWBto+I8CNpe+VI+K2J/F/k3PdkdTdz4wvGVH4NTpo+NRYTVIuMQEMMcsLqg==}
+ engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
+
minimatch@10.2.6:
resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==}
engines: {node: 18 || 20 || >=22}
+ minimatch@9.0.9:
+ resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==}
+ engines: {node: '>=16 || 14 >=14.17'}
+
minimist@1.2.8:
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
@@ -7673,10 +8071,18 @@ packages:
resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
engines: {node: '>=0.10.0'}
+ normalize-url@8.1.1:
+ resolution: {integrity: sha512-JYc0DPlpGWB40kH5g07gGTrYuMqV653k3uBKY6uITPWds3M0ov3GaWGp9lbE3Bzngx8+XkfzgvASb9vk9JDFXQ==}
+ engines: {node: '>=14.16'}
+
npm-run-path@4.0.1:
resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==}
engines: {node: '>=8'}
+ npm-run-path@5.3.0:
+ resolution: {integrity: sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==}
+ engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
+
npm-run-path@6.0.0:
resolution: {integrity: sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==}
engines: {node: '>=18'}
@@ -7724,6 +8130,10 @@ packages:
resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==}
engines: {node: '>=6'}
+ onetime@6.0.0:
+ resolution: {integrity: sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==}
+ engines: {node: '>=12'}
+
onetime@7.0.0:
resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==}
engines: {node: '>=18'}
@@ -7818,6 +8228,14 @@ packages:
vite-plus:
optional: true
+ p-cancelable@4.0.1:
+ resolution: {integrity: sha512-wBowNApzd45EIKdO1LaU+LrMBwAcjfPaYtVzV3lmfM3gf8Z4CHZsiIqlM8TZZ8okYvh5A1cP6gTfCRQtwUpaUg==}
+ engines: {node: '>=14.16'}
+
+ p-event@6.0.1:
+ resolution: {integrity: sha512-Q6Bekk5wpzW5qIyUP4gdMEujObYstZl6DMMOSenwBvV0BlE5LkDwkjs5yHbZmdCEq2o4RJx4tE1vwxFVf2FG1w==}
+ engines: {node: '>=16.17'}
+
p-limit@2.3.0:
resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==}
engines: {node: '>=6'}
@@ -7834,6 +8252,10 @@ packages:
resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
engines: {node: '>=10'}
+ p-timeout@6.1.4:
+ resolution: {integrity: sha512-MyIV3ZA/PmyBN/ud8vV9XzwTrNtR4jFrObymZYnZqMmW0zA8Z17vnT0rBgFE/TlohB+YCHqXMgZzb3Csp49vqg==}
+ engines: {node: '>=14.16'}
+
p-try@2.2.0:
resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==}
engines: {node: '>=6'}
@@ -7897,6 +8319,9 @@ packages:
peberminta@0.9.0:
resolution: {integrity: sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==}
+ pend@1.2.0:
+ resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==}
+
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
@@ -7912,6 +8337,9 @@ packages:
resolution: {integrity: sha512-0Z++uU8mvB0EvCPAEUq9BGiPcSravzJ+RPdXfjYlzXffqsogisiKRQqI6ctrSSJ6n985vxrgKtQ5n4sRINcJZg==}
engines: {node: '>=18.0.0'}
+ piscina@4.9.4:
+ resolution: {integrity: sha512-RyBDr2VheQ8ZfH3N8SzQZHztqVyOtadTwLnUYof6gdj5161/eu2wNhCbGl5GHnNKDpnkicJYpKtL2J+y/gk6XA==}
+
pkce-challenge@5.0.1:
resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==}
engines: {node: '>=16.20.0'}
@@ -8073,6 +8501,10 @@ packages:
queue-microtask@1.2.3:
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
+ quick-lru@5.1.1:
+ resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==}
+ engines: {node: '>=10'}
+
range-parser@1.3.0:
resolution: {integrity: sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==}
engines: {node: '>= 0.6'}
@@ -8271,9 +8703,16 @@ packages:
'@react-email/render':
optional: true
+ resolve-alpn@1.2.1:
+ resolution: {integrity: sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==}
+
resolve-pkg-maps@1.0.0:
resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
+ responselike@4.0.2:
+ resolution: {integrity: sha512-cGk8IbWEAnaCpdAt1BHzJ3Ahz5ewDJa0KseTsE3qIRMJ3C698W8psM7byCeWVpd/Ha7FUYzuRVzXoKoM6nRUbA==}
+ engines: {node: '>=20'}
+
restore-cursor@5.1.0:
resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==}
engines: {node: '>=18'}
@@ -8362,9 +8801,21 @@ packages:
secure-json-parse@4.1.0:
resolution: {integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==}
+ seek-bzip@2.0.0:
+ resolution: {integrity: sha512-SMguiTnYrhpLdk3PwfzHeotrcwi8bNV4iemL9tx9poR/yeaMYwB9VzR1w7b57DuWpuqR8n6oZboi0hj3AxZxQg==}
+ hasBin: true
+
selderee@0.11.0:
resolution: {integrity: sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA==}
+ semver-regex@4.0.5:
+ resolution: {integrity: sha512-hunMQrEy1T6Jr2uEVjrAIqjwWcQTgOAcIM52C8MY1EZSD3DDNft04XzvYKPqjED65bNVVko0YI38nYeEHCX3yw==}
+ engines: {node: '>=12'}
+
+ semver-truncate@3.0.0:
+ resolution: {integrity: sha512-LJWA9kSvMolR51oDE6PN3kALBNaUdkxzAGcexw8gjMA8xr5zUqK0JiR3CgARSqanYF3Z1YHvsErb1KDgh+v7Rg==}
+ engines: {node: '>=12'}
+
semver@6.3.1:
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
hasBin: true
@@ -8507,6 +8958,14 @@ packages:
'@types/react':
optional: true
+ sort-keys-length@1.0.1:
+ resolution: {integrity: sha512-GRbEOUqCxemTAk/b32F2xa8wDTs+Z1QHOkbhJDQTvv/6G3ZkbJ+frYWsTcc7cBB3Fu4wy4XlLCuNtJuMn7Gsvw==}
+ engines: {node: '>=0.10.0'}
+
+ sort-keys@1.1.2:
+ resolution: {integrity: sha512-vzn8aSqKgytVik0iwdBEi+zevbTYZogewTUM6dtpmGwEcdzbub/TX4bCzRhebDCRC3QzXgJsLRKB2V/Oof7HXg==}
+ engines: {node: '>=0.10.0'}
+
source-map-js@1.2.2:
resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==}
engines: {node: '>=0.10.0'}
@@ -8550,6 +9009,9 @@ packages:
resolution: {integrity: sha512-eCPu1qRxPVkl5605OTWF8Wz40b4Mf45NY5LQmVPQ599knfs5QhASUm9GbJ5BDMDOXgrnh0wyEdvzmL//YMlw0A==}
engines: {node: '>=18'}
+ streamx@2.28.1:
+ resolution: {integrity: sha512-zEzXb0s5Cds7tqMH6rhZ05lcJydCWiQPEwiNngVqzsxCc962vLY4Uw+mW7od8kDH258k2Uz/JrOkdIAAhSh9VA==}
+
string-ts@2.3.1:
resolution: {integrity: sha512-xSJq+BS52SaFFAVxuStmx6n5aYZU571uYUnUrPXkPFCfdHyZMMlbP2v2Wx5sNBnAVzq/2+0+mcBLBa3Xa5ubYw==}
@@ -8584,14 +9046,34 @@ packages:
resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==}
engines: {node: '>=4'}
+ strip-dirs@3.0.0:
+ resolution: {integrity: sha512-I0sdgcFTfKQlUPZyAqPJmSG3HLO9rWDFnxonnIbskYNM3DwFOeTNB5KzVq3dA1GdRAc/25b5Y7UO2TQfKWw4aQ==}
+
strip-final-newline@2.0.0:
resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==}
engines: {node: '>=6'}
+ strip-final-newline@3.0.0:
+ resolution: {integrity: sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==}
+ engines: {node: '>=12'}
+
strip-final-newline@4.0.0:
resolution: {integrity: sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==}
engines: {node: '>=18'}
+ stripe@23.0.0:
+ resolution: {integrity: sha512-MDE2A5ZVVluYURORaZEVghSmnLJc3w6aHAlZSxDQMsOjOb3V0O6ro/CiaeFlJS4N7uIVmsg98ocRsPmUK6YfyA==}
+ engines: {node: '>=20'}
+ peerDependencies:
+ '@types/node': '>=20'
+ peerDependenciesMeta:
+ '@types/node':
+ optional: true
+
+ strtok3@10.3.5:
+ resolution: {integrity: sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==}
+ engines: {node: '>=18'}
+
stubborn-fs@2.0.0:
resolution: {integrity: sha512-Y0AvSwDw8y+nlSNFXMm2g6L51rBGdAQT20J3YSOqxC53Lo3bjWRtr2BKcfYoAf352WYpsZSTURrA0tqhfgudPA==}
@@ -8617,10 +9099,18 @@ packages:
babel-plugin-macros:
optional: true
+ super-regex@1.1.0:
+ resolution: {integrity: sha512-WHkws2ZflZe41zj6AolvvmaTrWds/VuyeYr9iPVv/oQeaIoVxMKaushfFWpOGDT+GuBrM/sVqF8KUCYQlSSTdQ==}
+ engines: {node: '>=18'}
+
superjson@2.2.6:
resolution: {integrity: sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==}
engines: {node: '>=16'}
+ system-architecture@1.0.0:
+ resolution: {integrity: sha512-0OJWD12D7XX3KUg1DYkMaTTjSTo2k/mhIYI3HlBlceXSMcJhW/1qO735fPKS5prcyjvn57Ub151vvASYXpQrEw==}
+ engines: {node: '>=18'}
+
systeminformation@5.33.15:
resolution: {integrity: sha512-s5RloL0F7xhN0ZjfHtLGnbf7EW74EI0N8NFL+1xR7Oxz9QA33obNZsl7lyfnJOhN04E/kGFPoJPlvY7TZ5fzmw==}
engines: {node: '>=10.0.0'}
@@ -8641,6 +9131,19 @@ packages:
resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==}
engines: {node: '>=6'}
+ tar-stream@3.1.7:
+ resolution: {integrity: sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==}
+
+ text-decoder@1.2.7:
+ resolution: {integrity: sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==}
+
+ through@2.3.8:
+ resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==}
+
+ time-span@5.1.0:
+ resolution: {integrity: sha512-75voc/9G4rDIJleOo4jPvN4/YC4GRZrY8yy1uU4lwrB3XEQbWve8zXoO5No4eFrGcTAMYyoY67p8jRQdtA1HbA==}
+ engines: {node: '>=12'}
+
tiny-invariant@1.3.3:
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
@@ -8679,6 +9182,10 @@ packages:
resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==}
engines: {node: '>=0.6'}
+ token-types@6.1.2:
+ resolution: {integrity: sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==}
+ engines: {node: '>=14.16'}
+
tough-cookie@6.0.2:
resolution: {integrity: sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==}
engines: {node: '>=16'}
@@ -8807,6 +9314,9 @@ packages:
resolution: {integrity: sha512-8iAasVS4wUx0gPLjH8Xtz2PeDzTSiy8QiLGu2DT3X5GU+egFEQhpnbtkehbAwjvDcxIERmCYcysiODFmE3Ud0Q==}
engines: {node: '>=18'}
+ unbzip2-stream@1.4.3:
+ resolution: {integrity: sha512-mlExGW4w71ebDJviH16lQLtZS32VKqsSfk80GCfUlwT/4/hNRFsoscrF/c++9xinkMzECL1uL9DDwXqFWkruPg==}
+
unconfig-core@7.5.0:
resolution: {integrity: sha512-Su3FauozOGP44ZmKdHy2oE6LPjk51M/TRRjHv2HNCWiDvfvCoxC2lno6jevMA91MYAdCdwP05QnWdWpSbncX/w==}
@@ -9085,6 +9595,9 @@ packages:
web-namespaces@2.0.1:
resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==}
+ web-worker@1.5.0:
+ resolution: {integrity: sha512-RiMReJrTAiA+mBjGONMnjVDP2u3p9R1vkcGz6gDIrOMT3oGuYwX2WRMYI9ipkphSuE5XKEhydbhNEJh4NY9mlw==}
+
webidl-conversions@8.0.1:
resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==}
engines: {node: '>=20'}
@@ -9198,6 +9711,10 @@ packages:
resolution: {integrity: sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==}
engines: {node: '>=12'}
+ yauzl@3.4.0:
+ resolution: {integrity: sha512-jIH9yLR9wqr0wOS0TpBvo/g/2UgZH5qePVbjgRliiF0BYvOZyaBknKsF+x9Iht0O6sqgnB93rCICdOZFecJuDw==}
+ engines: {node: '>=12'}
+
yjs@13.6.33:
resolution: {integrity: sha512-q/UYvr1gOk4XII+Cu1CW2dINjYqJJFifTzogEaIARznrvX7uOlHsdTEXcnOBMuIsrCGEEKdNxi74kyMLZlKVfw==}
engines: {node: '>=16.0.0', npm: '>=8.0.0'}
@@ -9717,6 +10234,8 @@ snapshots:
'@bcoe/v8-coverage@1.0.2': {}
+ '@borewit/text-codec@0.2.2': {}
+
'@bramus/specificity@2.4.3':
dependencies:
css-tree: 3.2.1
@@ -10605,7 +11124,79 @@ snapshots:
transitivePeerDependencies:
- supports-color
- '@napi-rs/lzma-linux-x64-gnu@1.5.1':
+ '@napi-rs/lzma-linux-x64-gnu@1.5.1':
+ optional: true
+
+ '@napi-rs/nice-android-arm-eabi@1.1.1':
+ optional: true
+
+ '@napi-rs/nice-android-arm64@1.1.1':
+ optional: true
+
+ '@napi-rs/nice-darwin-arm64@1.1.1':
+ optional: true
+
+ '@napi-rs/nice-darwin-x64@1.1.1':
+ optional: true
+
+ '@napi-rs/nice-freebsd-x64@1.1.1':
+ optional: true
+
+ '@napi-rs/nice-linux-arm-gnueabihf@1.1.1':
+ optional: true
+
+ '@napi-rs/nice-linux-arm64-gnu@1.1.1':
+ optional: true
+
+ '@napi-rs/nice-linux-arm64-musl@1.1.1':
+ optional: true
+
+ '@napi-rs/nice-linux-ppc64-gnu@1.1.1':
+ optional: true
+
+ '@napi-rs/nice-linux-riscv64-gnu@1.1.1':
+ optional: true
+
+ '@napi-rs/nice-linux-s390x-gnu@1.1.1':
+ optional: true
+
+ '@napi-rs/nice-linux-x64-gnu@1.1.1':
+ optional: true
+
+ '@napi-rs/nice-linux-x64-musl@1.1.1':
+ optional: true
+
+ '@napi-rs/nice-openharmony-arm64@1.1.1':
+ optional: true
+
+ '@napi-rs/nice-win32-arm64-msvc@1.1.1':
+ optional: true
+
+ '@napi-rs/nice-win32-ia32-msvc@1.1.1':
+ optional: true
+
+ '@napi-rs/nice-win32-x64-msvc@1.1.1':
+ optional: true
+
+ '@napi-rs/nice@1.1.1':
+ optionalDependencies:
+ '@napi-rs/nice-android-arm-eabi': 1.1.1
+ '@napi-rs/nice-android-arm64': 1.1.1
+ '@napi-rs/nice-darwin-arm64': 1.1.1
+ '@napi-rs/nice-darwin-x64': 1.1.1
+ '@napi-rs/nice-freebsd-x64': 1.1.1
+ '@napi-rs/nice-linux-arm-gnueabihf': 1.1.1
+ '@napi-rs/nice-linux-arm64-gnu': 1.1.1
+ '@napi-rs/nice-linux-arm64-musl': 1.1.1
+ '@napi-rs/nice-linux-ppc64-gnu': 1.1.1
+ '@napi-rs/nice-linux-riscv64-gnu': 1.1.1
+ '@napi-rs/nice-linux-s390x-gnu': 1.1.1
+ '@napi-rs/nice-linux-x64-gnu': 1.1.1
+ '@napi-rs/nice-linux-x64-musl': 1.1.1
+ '@napi-rs/nice-openharmony-arm64': 1.1.1
+ '@napi-rs/nice-win32-arm64-msvc': 1.1.1
+ '@napi-rs/nice-win32-ia32-msvc': 1.1.1
+ '@napi-rs/nice-win32-x64-msvc': 1.1.1
optional: true
'@napi-rs/wasm-runtime@1.2.4(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.3)':
@@ -11440,93 +12031,48 @@ snapshots:
'@rolldown/binding-android-arm-eabi@1.2.12':
optional: true
- '@rolldown/binding-android-arm-eabi@1.2.13':
- optional: true
-
'@rolldown/binding-android-arm64@1.2.12':
optional: true
- '@rolldown/binding-android-arm64@1.2.13':
- optional: true
-
'@rolldown/binding-darwin-arm64@1.2.12':
optional: true
- '@rolldown/binding-darwin-arm64@1.2.13':
- optional: true
-
'@rolldown/binding-darwin-x64@1.2.12':
optional: true
- '@rolldown/binding-darwin-x64@1.2.13':
- optional: true
-
'@rolldown/binding-freebsd-x64@1.2.12':
optional: true
- '@rolldown/binding-freebsd-x64@1.2.13':
- optional: true
-
'@rolldown/binding-linux-arm-gnueabihf@1.2.12':
optional: true
- '@rolldown/binding-linux-arm-gnueabihf@1.2.13':
- optional: true
-
'@rolldown/binding-linux-arm64-gnu@1.2.12':
optional: true
- '@rolldown/binding-linux-arm64-gnu@1.2.13':
- optional: true
-
'@rolldown/binding-linux-arm64-musl@1.2.12':
optional: true
- '@rolldown/binding-linux-arm64-musl@1.2.13':
- optional: true
-
'@rolldown/binding-linux-ppc64-gnu@1.2.12':
optional: true
- '@rolldown/binding-linux-ppc64-gnu@1.2.13':
- optional: true
-
'@rolldown/binding-linux-s390x-gnu@1.2.12':
optional: true
- '@rolldown/binding-linux-s390x-gnu@1.2.13':
- optional: true
-
'@rolldown/binding-linux-x64-gnu@1.2.12':
optional: true
- '@rolldown/binding-linux-x64-gnu@1.2.13':
- optional: true
-
'@rolldown/binding-linux-x64-musl@1.2.12':
optional: true
- '@rolldown/binding-linux-x64-musl@1.2.13':
- optional: true
-
'@rolldown/binding-openharmony-arm64@1.2.12':
optional: true
- '@rolldown/binding-openharmony-arm64@1.2.13':
- optional: true
-
'@rolldown/binding-win32-arm64-msvc@1.2.12':
optional: true
- '@rolldown/binding-win32-arm64-msvc@1.2.13':
- optional: true
-
'@rolldown/binding-win32-x64-msvc@1.2.12':
optional: true
- '@rolldown/binding-win32-x64-msvc@1.2.13':
- optional: true
-
'@rolldown/pluginutils@1.0.1': {}
'@rollup/pluginutils@5.4.0(rollup@4.63.5)':
@@ -11755,6 +12301,8 @@ snapshots:
'@sindresorhus/is@4.6.0': {}
+ '@sindresorhus/is@7.2.0': {}
+
'@sindresorhus/merge-streams@4.0.0': {}
'@smithy/core@3.35.2':
@@ -11876,10 +12424,90 @@ snapshots:
iceberg-js: 0.8.1
tslib: 2.8.1
+ '@swc/cli@0.8.1(@swc/core@1.16.13(@swc/helpers@0.5.23))(chokidar@5.0.0)':
+ dependencies:
+ '@swc/core': 1.16.13(@swc/helpers@0.5.23)
+ '@swc/counter': 0.1.3
+ '@xhmikosr/bin-wrapper': 14.5.1
+ commander: 8.3.0
+ minimatch: 9.0.9
+ piscina: 4.9.4
+ semver: 7.8.5
+ slash: 3.0.0
+ source-map: 0.7.6
+ tinyglobby: 0.2.17
+ optionalDependencies:
+ chokidar: 5.0.0
+ transitivePeerDependencies:
+ - bare-abort-controller
+ - react-native-b4a
+ - supports-color
+
+ '@swc/core-darwin-arm64@1.16.13':
+ optional: true
+
+ '@swc/core-darwin-x64@1.16.13':
+ optional: true
+
+ '@swc/core-linux-arm-gnueabihf@1.16.13':
+ optional: true
+
+ '@swc/core-linux-arm64-gnu@1.16.13':
+ optional: true
+
+ '@swc/core-linux-arm64-musl@1.16.13':
+ optional: true
+
+ '@swc/core-linux-ppc64-gnu@1.16.13':
+ optional: true
+
+ '@swc/core-linux-s390x-gnu@1.16.13':
+ optional: true
+
+ '@swc/core-linux-x64-gnu@1.16.13':
+ optional: true
+
+ '@swc/core-linux-x64-musl@1.16.13':
+ optional: true
+
+ '@swc/core-win32-arm64-msvc@1.16.13':
+ optional: true
+
+ '@swc/core-win32-ia32-msvc@1.16.13':
+ optional: true
+
+ '@swc/core-win32-x64-msvc@1.16.13':
+ optional: true
+
+ '@swc/core@1.16.13(@swc/helpers@0.5.23)':
+ dependencies:
+ '@swc/counter': 0.1.3
+ '@swc/types': 0.1.28
+ optionalDependencies:
+ '@swc/core-darwin-arm64': 1.16.13
+ '@swc/core-darwin-x64': 1.16.13
+ '@swc/core-linux-arm-gnueabihf': 1.16.13
+ '@swc/core-linux-arm64-gnu': 1.16.13
+ '@swc/core-linux-arm64-musl': 1.16.13
+ '@swc/core-linux-ppc64-gnu': 1.16.13
+ '@swc/core-linux-s390x-gnu': 1.16.13
+ '@swc/core-linux-x64-gnu': 1.16.13
+ '@swc/core-linux-x64-musl': 1.16.13
+ '@swc/core-win32-arm64-msvc': 1.16.13
+ '@swc/core-win32-ia32-msvc': 1.16.13
+ '@swc/core-win32-x64-msvc': 1.16.13
+ '@swc/helpers': 0.5.23
+
+ '@swc/counter@0.1.3': {}
+
'@swc/helpers@0.5.23':
dependencies:
tslib: 2.8.1
+ '@swc/types@0.1.28':
+ dependencies:
+ '@swc/counter': 0.1.3
+
'@tailwindcss/node@4.3.3':
dependencies:
'@jridgewell/remapping': 2.3.5
@@ -12656,6 +13284,15 @@ snapshots:
y-protocols: 1.0.7(yjs@13.6.33)
yjs: 13.6.33
+ '@tokenizer/inflate@0.4.1':
+ dependencies:
+ debug: 4.4.3
+ token-types: 6.1.2
+ transitivePeerDependencies:
+ - supports-color
+
+ '@tokenizer/token@0.3.0': {}
+
'@ts-morph/common@0.27.0':
dependencies:
fast-glob: 3.3.3
@@ -12740,6 +13377,8 @@ snapshots:
dependencies:
'@types/unist': 3.0.3
+ '@types/http-cache-semantics@4.2.0': {}
+
'@types/json-schema@7.0.15': {}
'@types/mdast@4.0.4':
@@ -12938,6 +13577,99 @@ snapshots:
'@workflow/serde@4.1.0': {}
+ '@xhmikosr/archive-type@8.1.0':
+ dependencies:
+ file-type: 21.3.4
+ transitivePeerDependencies:
+ - supports-color
+
+ '@xhmikosr/bin-check@8.3.0':
+ dependencies:
+ isexe: 4.0.0
+ tinyexec: 1.3.1
+
+ '@xhmikosr/bin-wrapper@14.5.1':
+ dependencies:
+ '@xhmikosr/bin-check': 8.3.0
+ '@xhmikosr/downloader': 16.3.1
+ '@xhmikosr/os-filter-obj': 4.1.0
+ binary-version-check: 6.1.0
+ transitivePeerDependencies:
+ - bare-abort-controller
+ - react-native-b4a
+ - supports-color
+
+ '@xhmikosr/decompress-tar@9.0.2':
+ dependencies:
+ file-type: 21.3.4
+ is-stream: 4.0.1
+ tar-stream: 3.1.7
+ transitivePeerDependencies:
+ - bare-abort-controller
+ - react-native-b4a
+ - supports-color
+
+ '@xhmikosr/decompress-tarbz2@9.0.2':
+ dependencies:
+ '@xhmikosr/decompress-tar': 9.0.2
+ file-type: 21.3.4
+ is-stream: 4.0.1
+ seek-bzip: 2.0.0
+ unbzip2-stream: 1.4.3
+ transitivePeerDependencies:
+ - bare-abort-controller
+ - react-native-b4a
+ - supports-color
+
+ '@xhmikosr/decompress-targz@9.0.1':
+ dependencies:
+ '@xhmikosr/decompress-tar': 9.0.2
+ file-type: 21.3.4
+ is-stream: 4.0.1
+ transitivePeerDependencies:
+ - bare-abort-controller
+ - react-native-b4a
+ - supports-color
+
+ '@xhmikosr/decompress-unzip@8.2.1':
+ dependencies:
+ file-type: 21.3.4
+ get-stream: 9.0.1
+ yauzl: 3.4.0
+ transitivePeerDependencies:
+ - supports-color
+
+ '@xhmikosr/decompress@11.1.4':
+ dependencies:
+ '@xhmikosr/decompress-tar': 9.0.2
+ '@xhmikosr/decompress-tarbz2': 9.0.2
+ '@xhmikosr/decompress-targz': 9.0.1
+ '@xhmikosr/decompress-unzip': 8.2.1
+ graceful-fs: 4.2.11
+ strip-dirs: 3.0.0
+ transitivePeerDependencies:
+ - bare-abort-controller
+ - react-native-b4a
+ - supports-color
+
+ '@xhmikosr/downloader@16.3.1':
+ dependencies:
+ '@xhmikosr/archive-type': 8.1.0
+ '@xhmikosr/decompress': 11.1.4
+ content-disposition: 2.0.1
+ ext-name: 5.0.0
+ file-type: 21.3.4
+ filenamify: 7.0.3
+ got: 14.6.6
+ transitivePeerDependencies:
+ - bare-abort-controller
+ - react-native-b4a
+ - supports-color
+
+ '@xhmikosr/os-filter-obj@4.1.0':
+ dependencies:
+ system-architecture: 1.0.0
+
'@yuku-analyzer/binding-android-arm64@0.10.2':
optional: true
@@ -13177,6 +13909,8 @@ snapshots:
stubborn-fs: 2.0.0
when-exit: 2.1.5
+ b4a@1.9.0: {}
+
babel-dead-code-elimination@1.0.12:
dependencies:
'@babel/core': 7.29.7
@@ -13193,8 +13927,14 @@ snapshots:
bail@2.0.2: {}
+ balanced-match@1.0.2: {}
+
balanced-match@4.0.4: {}
+ bare-events@2.9.2: {}
+
+ base64-js@1.5.1: {}
+
baseline-browser-mapping@2.11.27: {}
baseline-browser-mapping@2.11.28: {}
@@ -13205,6 +13945,17 @@ snapshots:
binary-extensions@2.3.0: {}
+ binary-version-check@6.1.0:
+ dependencies:
+ binary-version: 7.1.0
+ semver: 7.8.5
+ semver-truncate: 3.0.0
+
+ binary-version@7.1.0:
+ dependencies:
+ execa: 8.0.1
+ find-versions: 6.0.0
+
bippy@0.5.43(react@19.3.0):
dependencies:
react: 19.3.0
@@ -13229,6 +13980,10 @@ snapshots:
bowser@2.14.1: {}
+ brace-expansion@2.1.7:
+ dependencies:
+ balanced-match: 1.0.2
+
brace-expansion@5.0.12:
dependencies:
balanced-match: 4.0.4
@@ -13249,14 +14004,33 @@ snapshots:
dependencies:
'@types/node': 26.6.4
+ buffer@5.7.1:
+ dependencies:
+ base64-js: 1.5.1
+ ieee754: 1.2.1
+
bundle-name@4.1.1:
dependencies:
run-applescript: 7.1.0
+ byte-counter@0.1.0: {}
+
bytes@3.1.2: {}
cac@7.0.1: {}
+ cacheable-lookup@7.0.0: {}
+
+ cacheable-request@13.0.19:
+ dependencies:
+ '@types/http-cache-semantics': 4.2.0
+ get-stream: 9.0.1
+ http-cache-semantics: 4.3.0
+ keyv: 5.6.0
+ mimic-response: 4.0.0
+ normalize-url: 8.1.1
+ responselike: 4.0.2
+
cacheable@2.5.0:
dependencies:
'@cacheable/memory': 2.2.0
@@ -13375,6 +14149,10 @@ snapshots:
commander@15.0.0: {}
+ commander@6.2.1: {}
+
+ commander@8.3.0: {}
+
commander@9.5.0: {}
compare-versions@6.1.1: {}
@@ -13412,10 +14190,14 @@ snapshots:
content-disposition@1.1.0: {}
+ content-disposition@2.0.1: {}
+
content-type@1.0.5: {}
content-type@2.1.0: {}
+ convert-hrtime@5.0.0: {}
+
convert-source-map@2.0.0: {}
cookie-es@3.1.1: {}
@@ -13598,6 +14380,10 @@ snapshots:
dependencies:
character-entities: 2.0.2
+ decompress-response@10.0.0:
+ dependencies:
+ mimic-response: 4.0.0
+
dedent@1.7.2: {}
deep-is@0.1.4: {}
@@ -14119,6 +14905,12 @@ snapshots:
etag@1.8.1: {}
+ events-universal@1.0.1:
+ dependencies:
+ bare-events: 2.9.2
+ transitivePeerDependencies:
+ - bare-abort-controller
+
eventsource-parser@3.1.1: {}
eventsource@3.0.7:
@@ -14137,6 +14929,18 @@ snapshots:
signal-exit: 3.0.7
strip-final-newline: 2.0.0
+ execa@8.0.1:
+ dependencies:
+ cross-spawn: 7.0.6
+ get-stream: 8.0.1
+ human-signals: 5.0.0
+ is-stream: 3.0.0
+ merge-stream: 2.0.0
+ npm-run-path: 5.3.0
+ onetime: 6.0.0
+ signal-exit: 4.1.0
+ strip-final-newline: 3.0.0
+
execa@9.6.1:
dependencies:
'@sindresorhus/merge-streams': 4.0.0
@@ -14197,12 +15001,23 @@ snapshots:
exsolve@1.1.1: {}
+ ext-list@2.2.2:
+ dependencies:
+ mime-db: 1.54.0
+
+ ext-name@5.0.0:
+ dependencies:
+ ext-list: 2.2.2
+ sort-keys-length: 1.0.1
+
extend@3.0.2: {}
fast-deep-equal@3.1.3: {}
fast-equals@5.4.3: {}
+ fast-fifo@1.3.2: {}
+
fast-glob@3.3.3:
dependencies:
'@nodelib/fs.stat': 2.0.5
@@ -14247,6 +15062,21 @@ snapshots:
dependencies:
flat-cache: 6.1.23
+ file-type@21.3.4:
+ dependencies:
+ '@tokenizer/inflate': 0.4.1
+ strtok3: 10.3.5
+ token-types: 6.1.2
+ uint8array-extras: 1.6.0
+ transitivePeerDependencies:
+ - supports-color
+
+ filename-reserved-regex@4.1.0: {}
+
+ filenamify@7.0.3:
+ dependencies:
+ filename-reserved-regex: 4.1.0
+
fill-range@7.1.1:
dependencies:
to-regex-range: 5.0.1
@@ -14271,6 +15101,11 @@ snapshots:
locate-path: 6.0.0
path-exists: 4.0.0
+ find-versions@6.0.0:
+ dependencies:
+ semver-regex: 4.0.5
+ super-regex: 1.1.0
+
flat-cache@6.1.23:
dependencies:
cacheable: 2.5.0
@@ -14282,6 +15117,8 @@ snapshots:
flatted@3.4.4: {}
+ form-data-encoder@4.1.0: {}
+
forwarded@0.2.0: {}
framer-motion@14.0.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0):
@@ -14379,6 +15216,8 @@ snapshots:
function-bind@1.1.2: {}
+ function-timeout@1.0.2: {}
+
fuzzysort@3.1.0: {}
gensync@1.0.0-beta.2: {}
@@ -14411,6 +15250,8 @@ snapshots:
get-stream@6.0.1: {}
+ get-stream@8.0.1: {}
+
get-stream@9.0.1:
dependencies:
'@sec-ant/readable-stream': 0.4.1
@@ -14457,6 +15298,21 @@ snapshots:
gopd@1.2.0: {}
+ got@14.6.6:
+ dependencies:
+ '@sindresorhus/is': 7.2.0
+ byte-counter: 0.1.0
+ cacheable-lookup: 7.0.0
+ cacheable-request: 13.0.19
+ decompress-response: 10.0.0
+ form-data-encoder: 4.1.0
+ http2-wrapper: 2.2.1
+ keyv: 5.6.0
+ lowercase-keys: 3.0.0
+ p-cancelable: 4.0.1
+ responselike: 4.0.2
+ type-fest: 4.41.0
+
graceful-fs@4.2.11: {}
h3@2.0.1-rc.20(crossws@0.4.12(srvx@1.0.5)):
@@ -14622,6 +15478,8 @@ snapshots:
domutils: 3.2.2
entities: 4.5.0
+ http-cache-semantics@4.3.0: {}
+
http-errors@2.0.1:
dependencies:
depd: 2.0.0
@@ -14630,10 +15488,17 @@ snapshots:
statuses: 2.0.2
toidentifier: 1.0.1
+ http2-wrapper@2.2.1:
+ dependencies:
+ quick-lru: 5.1.1
+ resolve-alpn: 1.2.1
+
httpxy@0.5.5: {}
human-signals@2.1.0: {}
+ human-signals@5.0.0: {}
+
human-signals@8.0.1: {}
iceberg-js@0.8.1: {}
@@ -14646,6 +15511,8 @@ snapshots:
dependencies:
'@formatjs/icu-messageformat-parser': 3.5.21
+ ieee754@1.2.1: {}
+
ignore@5.3.2: {}
ignore@7.0.12: {}
@@ -14669,6 +15536,10 @@ snapshots:
react: 19.3.0
react-dom: 19.3.0(react@19.3.0)
+ inspect-with-kind@1.0.5:
+ dependencies:
+ kind-of: 6.0.3
+
internmap@1.0.1: {}
internmap@2.0.3: {}
@@ -14725,6 +15596,8 @@ snapshots:
is-obj@3.0.0: {}
+ is-plain-obj@1.1.0: {}
+
is-plain-obj@4.1.0: {}
is-plain-object@5.1.0: {}
@@ -14737,6 +15610,8 @@ snapshots:
is-stream@2.0.1: {}
+ is-stream@3.0.0: {}
+
is-stream@4.0.1: {}
is-unicode-supported@1.3.0: {}
@@ -14757,6 +15632,8 @@ snapshots:
isexe@3.1.5: {}
+ isexe@4.0.0: {}
+
isomorphic.js@0.2.5: {}
jiti@2.6.1: {}
@@ -14838,6 +15715,8 @@ snapshots:
dependencies:
'@keyv/serialize': 1.1.1
+ kind-of@6.0.3: {}
+
kleur@3.0.3: {}
kleur@4.1.5: {}
@@ -14983,6 +15862,8 @@ snapshots:
longest-streak@3.1.0: {}
+ lowercase-keys@3.0.0: {}
+
lru-cache@11.5.3: {}
lru-cache@5.1.1:
@@ -15017,6 +15898,12 @@ snapshots:
'@babel/types': 7.29.8
source-map-js: 1.2.2
+ make-asynchronous@1.1.0:
+ dependencies:
+ p-event: 6.0.1
+ type-fest: 4.41.0
+ web-worker: 1.5.0
+
markdown-extensions@2.0.0: {}
markdown-table@3.0.4: {}
@@ -15491,12 +16378,20 @@ snapshots:
mimic-fn@3.1.0: {}
+ mimic-fn@4.0.0: {}
+
mimic-function@5.0.1: {}
+ mimic-response@4.0.0: {}
+
minimatch@10.2.6:
dependencies:
brace-expansion: 5.0.12
+ minimatch@9.0.9:
+ dependencies:
+ brace-expansion: 2.1.7
+
minimist@1.2.8: {}
minipass@7.1.3: {}
@@ -15601,10 +16496,16 @@ snapshots:
normalize-path@3.0.0: {}
+ normalize-url@8.1.1: {}
+
npm-run-path@4.0.1:
dependencies:
path-key: 3.1.1
+ npm-run-path@5.3.0:
+ dependencies:
+ path-key: 4.0.0
+
npm-run-path@6.0.0:
dependencies:
path-key: 4.0.0
@@ -15642,6 +16543,10 @@ snapshots:
dependencies:
mimic-fn: 2.1.0
+ onetime@6.0.0:
+ dependencies:
+ mimic-fn: 4.0.0
+
onetime@7.0.0:
dependencies:
mimic-function: 5.0.1
@@ -15869,6 +16774,12 @@ snapshots:
'@oxlint/binding-win32-x64-msvc': 1.87.0
oxlint-tsgolint: 7.0.2003
+ p-cancelable@4.0.1: {}
+
+ p-event@6.0.1:
+ dependencies:
+ p-timeout: 6.1.4
+
p-limit@2.3.0:
dependencies:
p-try: 2.2.0
@@ -15885,6 +16796,8 @@ snapshots:
dependencies:
p-limit: 3.1.0
+ p-timeout@6.1.4: {}
+
p-try@2.2.0: {}
package-manager-detector@1.9.0: {}
@@ -15937,6 +16850,8 @@ snapshots:
peberminta@0.9.0: {}
+ pend@1.2.0: {}
+
picocolors@1.1.1: {}
picomatch@2.3.2: {}
@@ -15945,6 +16860,10 @@ snapshots:
picospinner@3.1.2: {}
+ piscina@4.9.4:
+ optionalDependencies:
+ '@napi-rs/nice': 1.1.1
+
pkce-challenge@5.0.1: {}
pkg-up@3.1.0:
@@ -16121,6 +17040,8 @@ snapshots:
queue-microtask@1.2.3: {}
+ quick-lru@5.1.1: {}
+
range-parser@1.3.0: {}
rate-limiter-flexible@11.2.1: {}
@@ -16436,8 +17357,14 @@ snapshots:
optionalDependencies:
'@react-email/render': 2.1.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
+ resolve-alpn@1.2.1: {}
+
resolve-pkg-maps@1.0.0: {}
+ responselike@4.0.2:
+ dependencies:
+ lowercase-keys: 3.0.0
+
restore-cursor@5.1.0:
dependencies:
onetime: 7.0.0
@@ -16486,22 +17413,6 @@ snapshots:
dependencies:
'@oxc-project/types': 0.153.0
'@rolldown/pluginutils': 1.0.1
- optionalDependencies:
- '@rolldown/binding-android-arm-eabi': 1.2.13
- '@rolldown/binding-android-arm64': 1.2.13
- '@rolldown/binding-darwin-arm64': 1.2.13
- '@rolldown/binding-darwin-x64': 1.2.13
- '@rolldown/binding-freebsd-x64': 1.2.13
- '@rolldown/binding-linux-arm-gnueabihf': 1.2.13
- '@rolldown/binding-linux-arm64-gnu': 1.2.13
- '@rolldown/binding-linux-arm64-musl': 1.2.13
- '@rolldown/binding-linux-ppc64-gnu': 1.2.13
- '@rolldown/binding-linux-s390x-gnu': 1.2.13
- '@rolldown/binding-linux-x64-gnu': 1.2.13
- '@rolldown/binding-linux-x64-musl': 1.2.13
- '@rolldown/binding-openharmony-arm64': 1.2.13
- '@rolldown/binding-win32-arm64-msvc': 1.2.13
- '@rolldown/binding-win32-x64-msvc': 1.2.13
rollup@4.63.5:
dependencies:
@@ -16582,10 +17493,20 @@ snapshots:
secure-json-parse@4.1.0: {}
+ seek-bzip@2.0.0:
+ dependencies:
+ commander: 6.2.1
+
selderee@0.11.0:
dependencies:
parseley: 0.12.1
+ semver-regex@4.0.5: {}
+
+ semver-truncate@3.0.0:
+ dependencies:
+ semver: 7.8.5
+
semver@6.3.1: {}
semver@7.8.5: {}
@@ -16820,6 +17741,14 @@ snapshots:
optionalDependencies:
'@types/react': 19.3.0
+ sort-keys-length@1.0.1:
+ dependencies:
+ sort-keys: 1.1.2
+
+ sort-keys@1.1.2:
+ dependencies:
+ is-plain-obj: 1.1.0
+
source-map-js@1.2.2: {}
source-map@0.6.1: {}
@@ -16845,6 +17774,15 @@ snapshots:
stdin-discarder@0.3.2: {}
+ streamx@2.28.1:
+ dependencies:
+ events-universal: 1.0.1
+ fast-fifo: 1.3.2
+ text-decoder: 1.2.7
+ transitivePeerDependencies:
+ - bare-abort-controller
+ - react-native-b4a
+
string-ts@2.3.1: {}
string-width@4.2.3:
@@ -16885,10 +17823,25 @@ snapshots:
strip-bom@3.0.0: {}
+ strip-dirs@3.0.0:
+ dependencies:
+ inspect-with-kind: 1.0.5
+ is-plain-obj: 1.1.0
+
strip-final-newline@2.0.0: {}
+ strip-final-newline@3.0.0: {}
+
strip-final-newline@4.0.0: {}
+ stripe@23.0.0(@types/node@26.6.4):
+ optionalDependencies:
+ '@types/node': 26.6.4
+
+ strtok3@10.3.5:
+ dependencies:
+ '@tokenizer/token': 0.3.0
+
stubborn-fs@2.0.0:
dependencies:
stubborn-utils: 1.0.2
@@ -16910,10 +17863,18 @@ snapshots:
optionalDependencies:
'@babel/core': 7.29.7
+ super-regex@1.1.0:
+ dependencies:
+ function-timeout: 1.0.2
+ make-asynchronous: 1.1.0
+ time-span: 5.1.0
+
superjson@2.2.6:
dependencies:
copy-anything: 4.1.5
+ system-architecture@1.0.0: {}
+
systeminformation@5.33.15: {}
tagged-tag@1.0.0: {}
@@ -16924,6 +17885,27 @@ snapshots:
tapable@2.3.3: {}
+ tar-stream@3.1.7:
+ dependencies:
+ b4a: 1.9.0
+ fast-fifo: 1.3.2
+ streamx: 2.28.1
+ transitivePeerDependencies:
+ - bare-abort-controller
+ - react-native-b4a
+
+ text-decoder@1.2.7:
+ dependencies:
+ b4a: 1.9.0
+ transitivePeerDependencies:
+ - react-native-b4a
+
+ through@2.3.8: {}
+
+ time-span@5.1.0:
+ dependencies:
+ convert-hrtime: 5.0.0
+
tiny-invariant@1.3.3: {}
tinybench@6.2.0: {}
@@ -16951,6 +17933,12 @@ snapshots:
toidentifier@1.0.1: {}
+ token-types@6.1.2:
+ dependencies:
+ '@borewit/text-codec': 0.2.2
+ '@tokenizer/token': 0.3.0
+ ieee754: 1.2.1
+
tough-cookie@6.0.2:
dependencies:
tldts: 7.4.16
@@ -17067,6 +18055,11 @@ snapshots:
uint8array-extras@1.6.0: {}
+ unbzip2-stream@1.4.3:
+ dependencies:
+ buffer: 5.7.1
+ through: 2.3.8
+
unconfig-core@7.5.0:
dependencies:
'@quansync/fs': 1.1.0
@@ -17277,6 +18270,8 @@ snapshots:
web-namespaces@2.0.1: {}
+ web-worker@1.5.0: {}
+
webidl-conversions@8.0.1: {}
webpack-virtual-modules@0.6.2: {}
@@ -17358,6 +18353,10 @@ snapshots:
y18n: 5.0.8
yargs-parser: 21.1.1
+ yauzl@3.4.0:
+ dependencies:
+ pend: 1.2.0
+
yjs@13.6.33:
dependencies:
lib0: 0.2.119