better-auth-paystack
Version:
Production-ready Paystack billing plugin for Better Auth. Supports subscriptions, one-time payments, organization billing, secure webhooks and more
3,885 lines • 144 kB
JavaScript
import { a as getMetadataBoolean, c as hasPaystackMetadata, i as createRenewalMetadata, l as parsePaystackMetadata, n as createCheckoutMetadata, o as getMetadataNumber, r as createProrationMetadata, s as getMetadataString, t as PACKAGE_VERSION, u as stringifyPaystackMetadata } from "./version-ClvErcin.mjs";
import { HIDE_METADATA, defineErrorCodes } from "better-auth";
import { APIError, createAuthEndpoint, createAuthMiddleware, getSessionFromCtx, originCheckMiddleware, sessionMiddleware } from "better-auth/api";
import { defu } from "defu";
import { PaystackError, PaystackResponse } from "@alexasomba/paystack-node";
import { createCipheriv, createDecipheriv, createHash, randomBytes, randomUUID } from "node:crypto";
import { z } from "zod";
import { Buffer } from "node:buffer";
import * as z$1 from "zod/v4";
import { mergeSchema } from "better-auth/db";
//#region src/models.ts
/** Provider-owned Better Auth model names. Keep these centralized so a provider
* model cannot accidentally fall back to Better Auth's generic billing tables. */
const PAYSTACK_MODELS = {
customer: "paystackCustomer",
paymentCredential: "paystackPaymentCredential",
subscription: "paystackSubscription",
transaction: "paystackTransaction",
product: "paystackProduct",
plan: "paystackPlan",
webhookEvent: "paystackWebhookEvent"
};
const LEGACY_PAYSTACK_MODELS = { subscription: "subscription" };
//#endregion
//#region src/billing-store.ts
function sortSubscriptionsForCurrent(subscriptions) {
const statusRank = /* @__PURE__ */ new Map([
["active", 0],
["trialing", 1],
["incomplete", 2],
["past_due", 3],
["canceled", 4]
]);
return [...subscriptions].sort((a, b) => {
const rankA = statusRank.get(a.status) ?? 99;
const rankB = statusRank.get(b.status) ?? 99;
if (rankA !== rankB) return rankA - rankB;
return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime();
});
}
function createBillingStore(ctx) {
return createBillingStoreFromAdapter(ctx.context.adapter);
}
function createBillingStoreFromAdapter(adapter) {
const findOne = async (model, where) => {
try {
return await adapter.findOne({
model,
where
}) ?? null;
} catch {
return null;
}
};
const findMany = async (model, where) => {
try {
return await adapter.findMany({
model,
...where ? { where } : {}
}) ?? [];
} catch {
return [];
}
};
return {
findSubscriptionById: (id) => findOne(PAYSTACK_MODELS.subscription, [{
field: "id",
value: id
}]),
findSubscriptionByCode: (subscriptionCode) => findOne(PAYSTACK_MODELS.subscription, [{
field: "subscriptionCode",
value: subscriptionCode
}]),
findSubscriptionsByReference: (referenceId) => findMany(PAYSTACK_MODELS.subscription, [{
field: "referenceId",
value: referenceId
}]),
async findCurrentSubscription(referenceId, groupId) {
const subscriptions = await this.findSubscriptionsByReference(referenceId);
return sortSubscriptionsForCurrent(groupId === void 0 ? subscriptions : subscriptions.filter((subscription) => groupId === null ? subscription.groupId === void 0 || subscription.groupId === null || subscription.groupId === "" : subscription.groupId === groupId))[0] ?? null;
},
async retireCompetingSubscriptions(referenceId, groupId, exceptId) {
const subscriptions = await this.findSubscriptionsByReference(referenceId);
const selected = subscriptions.find((subscription) => subscription.id === exceptId);
const competitors = subscriptions.filter((subscription) => subscription.id !== exceptId && (selected === void 0 || new Date(subscription.createdAt).getTime() <= new Date(selected.createdAt).getTime()) && (subscription.status === "active" || subscription.status === "trialing") && (groupId === null ? subscription.groupId === void 0 || subscription.groupId === null || subscription.groupId === "" : subscription.groupId === groupId));
const now = /* @__PURE__ */ new Date();
for (const subscription of competitors) await this.updateSubscription(subscription.id, {
status: "canceled",
cancelAtPeriodEnd: false,
canceledAt: now,
endedAt: now,
updatedAt: now
});
},
findSubscriptionsByTransactionReference: (reference) => findMany(PAYSTACK_MODELS.subscription, [{
field: "transactionReference",
value: reference
}]),
createSubscription: async (data) => await adapter.create({
model: PAYSTACK_MODELS.subscription,
data
}),
updateSubscription: (id, update) => adapter.update({
model: PAYSTACK_MODELS.subscription,
update,
where: [{
field: "id",
value: id
}]
}),
updateSubscriptionByCode: (subscriptionCode, update) => adapter.update({
model: PAYSTACK_MODELS.subscription,
update,
where: [{
field: "subscriptionCode",
value: subscriptionCode
}]
}),
createTransaction: async (data) => await adapter.create({
model: PAYSTACK_MODELS.transaction,
data
}),
findTransactionByReference: (reference) => findOne(PAYSTACK_MODELS.transaction, [{
field: "reference",
value: reference
}]),
updateTransactionByReference: (reference, update) => adapter.update({
model: PAYSTACK_MODELS.transaction,
update,
where: [{
field: "reference",
value: reference
}]
}),
createWebhookEvent: (data) => adapter.create({
model: PAYSTACK_MODELS.webhookEvent,
data
}),
findWebhookEvent: (eventId) => adapter.findOne({
model: PAYSTACK_MODELS.webhookEvent,
where: [{
field: "eventId",
value: eventId
}]
}),
updateWebhookEvent: (eventId, update) => adapter.update({
model: PAYSTACK_MODELS.webhookEvent,
update,
where: [{
field: "eventId",
value: eventId
}]
}),
async listTransactions(referenceId) {
return (await findMany(PAYSTACK_MODELS.transaction, [{
field: "referenceId",
value: referenceId
}])).sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
},
async listProducts() {
return (await findMany(PAYSTACK_MODELS.product)).sort((a, b) => a.name.localeCompare(b.name));
},
findProductByName: (name) => findOne(PAYSTACK_MODELS.product, [{
field: "name",
value: name
}]),
findProductBySlug: (slug) => findOne(PAYSTACK_MODELS.product, [{
field: "slug",
value: slug
}]),
async updateProduct(id, update) {
await adapter.update({
model: PAYSTACK_MODELS.product,
update,
where: [{
field: "id",
value: id
}]
});
},
async upsertProductByPaystackId(paystackId, data) {
const existing = await findOne(PAYSTACK_MODELS.product, [{
field: "paystackId",
value: paystackId
}]);
if (existing?.id !== void 0) {
const { createdAt: _createdAt, ...update } = data;
await adapter.update({
model: PAYSTACK_MODELS.product,
update,
where: [{
field: "id",
value: String(existing.id)
}]
});
return;
}
await adapter.create({
model: PAYSTACK_MODELS.product,
data
});
},
listPlans: () => findMany(PAYSTACK_MODELS.plan),
findPlanByName: (name) => findOne(PAYSTACK_MODELS.plan, [{
field: "name",
value: name
}]),
findPlanByCode: (planCode) => findOne(PAYSTACK_MODELS.plan, [{
field: "planCode",
value: planCode
}]),
async upsertPlanByPaystackId(paystackId, data) {
const existing = await findOne(PAYSTACK_MODELS.plan, [{
field: "paystackId",
value: paystackId
}]);
if (existing?.id !== void 0) {
const { createdAt: _createdAt, ...update } = data;
await adapter.update({
model: PAYSTACK_MODELS.plan,
update,
where: [{
field: "id",
value: existing.id
}]
});
return;
}
await adapter.create({
model: PAYSTACK_MODELS.plan,
data
});
},
findUser: (id) => findOne("user", [{
field: "id",
value: id
}]),
findOrganization: (id) => findOne("organization", [{
field: "id",
value: id
}]),
findOrganizationOwner: (organizationId) => findOne("member", [{
field: "organizationId",
value: organizationId
}, {
field: "role",
value: "owner"
}]),
listMembers: (organizationId) => findMany("member", [{
field: "organizationId",
value: organizationId
}]),
listTeams: (organizationId) => findMany("team", [{
field: "organizationId",
value: organizationId
}]),
findCustomerByReference: (referenceType, referenceId) => findOne(PAYSTACK_MODELS.customer, [{
field: "referenceKey",
value: `${referenceType}:${referenceId}`
}]),
findCustomerByCode: (customerCode) => findOne(PAYSTACK_MODELS.customer, [{
field: "customerCode",
value: customerCode
}]),
async saveCustomer(referenceType, referenceId, customerCode, email) {
const referenceKey = `${referenceType}:${referenceId}`;
const existing = await findOne(PAYSTACK_MODELS.customer, [{
field: "referenceKey",
value: referenceKey
}]);
const now = /* @__PURE__ */ new Date();
const hasEmail = email !== void 0 && email !== null && email !== "";
if (existing) return await adapter.update({
model: PAYSTACK_MODELS.customer,
update: {
customerCode,
...hasEmail ? { email } : {},
updatedAt: now
},
where: [{
field: "id",
value: existing.id
}]
}) ?? {
...existing,
customerCode,
email: email ?? existing.email,
updatedAt: now
};
return adapter.create({
model: PAYSTACK_MODELS.customer,
data: {
referenceType,
referenceId,
referenceKey,
customerCode,
...hasEmail ? { email } : {},
createdAt: now,
updatedAt: now
}
});
},
async saveCustomerCode(referenceId, customerCode, isOrganization, email) {
await this.saveCustomer(isOrganization ? "organization" : "user", referenceId, customerCode, email);
}
};
}
//#endregion
//#region src/paystack-sdk.ts
/**
* Interface for checking if a result is a PaystackResponse from the SDK v1.9.1+
*/
function IsPaystackResponse(value) {
return value instanceof PaystackResponse;
}
/**
* Unwraps a Paystack SDK result, extracting the data or throwing an APIError if the request failed.
* Leverages the native .unwrap() method in SDK v1.9.1+ if available.
*/
function unwrapSdkResult(result) {
if (IsPaystackResponse(result)) try {
return result.unwrap();
} catch (e) {
if (e instanceof PaystackError) throw new APIError(e.status === 404 ? "NOT_FOUND" : "BAD_REQUEST", {
message: e.message,
status: e.status
});
throw new APIError("BAD_REQUEST", { message: e?.message ?? "Paystack API error" });
}
let current = result;
while (current !== null && current !== void 0 && typeof current === "object") {
const body = current;
if (body.status === false) throw new APIError("BAD_REQUEST", { message: body.message ?? "Paystack API error" });
if ("authorization_url" in body || "reference" in body || "customer_code" in body) break;
if ("data" in body && body.data !== void 0 && body.data !== null && typeof body.data === "object") {
current = body.data;
continue;
}
break;
}
return current;
}
/**
* Returns the operations object from a Paystack client.
* For v1.9.1+, the client itself uses the grouped structure.
*/
function getPaystackOps(client) {
return client;
}
function createPaystackAdapter(client) {
const requireClient = () => {
if (client === void 0 || client === null) throw new APIError("BAD_REQUEST", { message: "Paystack client is not configured" });
return client;
};
return {
async initializeTransaction(body) {
return unwrapSdkResult(await requireClient().transaction?.initialize({ body }));
},
async verifyTransaction(reference) {
return unwrapSdkResult(await requireClient().transaction?.verify(reference));
},
async chargeAuthorization(body) {
return unwrapSdkResult(await requireClient().transaction?.chargeAuthorization({ body }));
},
async createCustomer(body) {
return unwrapSdkResult(await requireClient().customer?.create({ body }));
},
async fetchCustomer(emailOrCode) {
return unwrapSdkResult(await requireClient().customer?.fetch(emailOrCode));
},
async updateCustomer(emailOrCode, body) {
return unwrapSdkResult(await requireClient().customer?.update(emailOrCode, { body }));
},
async listProducts() {
return unwrapSdkResult(await requireClient().product?.list({}));
},
async fetchProduct(productId) {
return unwrapSdkResult(await requireClient().product?.fetch(productId));
},
async listPlans() {
return unwrapSdkResult(await requireClient().plan?.list());
},
async createSubscription(body) {
return unwrapSdkResult(await requireClient().subscription?.create({ body }));
},
async fetchSubscription(subscriptionCode) {
return unwrapSdkResult(await requireClient().subscription?.fetch(subscriptionCode));
},
async disableSubscription(body) {
return unwrapSdkResult(await requireClient().subscription?.disable({ body }));
},
async enableSubscription(body) {
return unwrapSdkResult(await requireClient().subscription?.enable({ body }));
},
async manageSubscriptionLink(subscriptionCode) {
return unwrapSdkResult(await requireClient().subscription?.manageLink(subscriptionCode));
}
};
}
//#endregion
//#region src/customer.ts
function isNotFound(error) {
if (error === null || typeof error !== "object") return false;
const candidate = error;
return candidate.status === 404 || candidate.statusCode === 404;
}
function customerCode(customer) {
if (customer === null || typeof customer !== "object") return void 0;
const code = customer.customer_code;
return typeof code === "string" && code !== "" ? code : void 0;
}
function ownsCustomer(customer, reference) {
if (customer === null || typeof customer !== "object") return false;
const metadata = parsePaystackMetadata(customer.metadata);
const expectedIdKey = reference.type === "user" ? "userId" : "organizationId";
const otherIdKey = reference.type === "user" ? "organizationId" : "userId";
return metadata[expectedIdKey] === reference.id && metadata[otherIdKey] === void 0 && (metadata.customerType === void 0 || metadata.customerType === reference.type);
}
function hasOwner(customer) {
if (customer === null || typeof customer !== "object") return false;
const metadata = parsePaystackMetadata(customer.metadata);
return typeof metadata.userId === "string" || typeof metadata.organizationId === "string" || typeof metadata.customerType === "string";
}
function ownershipMetadata(reference, existing) {
return stringifyPaystackMetadata({
...parsePaystackMetadata(existing),
customerType: reference.type,
...reference.type === "user" ? { userId: reference.id } : { organizationId: reference.id }
});
}
async function resolvePaystackCustomer(input) {
const { adapter, client, logger, reference } = input;
const store = createBillingStoreFromAdapter(adapter);
const persisted = await store.findCustomerByReference(reference.type, reference.id);
let legacyCode;
try {
legacyCode = (await adapter.findOne({
model: reference.type,
where: [{
field: "id",
value: reference.id
}],
select: ["paystackCustomerCode"]
}))?.paystackCustomerCode ?? void 0;
} catch {}
const existingCode = reference.paystackCustomerCode ?? persisted?.customerCode ?? legacyCode;
if (typeof existingCode === "string" && existingCode !== "") {
if (persisted === null) await store.saveCustomer(reference.type, reference.id, existingCode, reference.email);
return null;
}
const sdk = createPaystackAdapter(client);
let existing = null;
if (typeof client.customer?.fetch === "function") try {
existing = await sdk.fetchCustomer(reference.email) ?? null;
} catch (error) {
if (!isNotFound(error)) {
logger.error("Failed to look up Paystack customer; customer creation was skipped", error);
return null;
}
}
if (existing !== null) {
const code = customerCode(existing);
if (code === void 0) {
logger.error("Paystack customer lookup returned no customer code");
return null;
}
const owned = ownsCustomer(existing, reference);
const canClaimLegacy = reference.type === "user" && reference.emailVerified === true && !hasOwner(existing);
if (!owned && !canClaimLegacy) {
logger.error("Paystack customer belongs to another billing reference");
return null;
}
const existingMetadata = existing.metadata;
if (canClaimLegacy || parsePaystackMetadata(existingMetadata).customerType !== reference.type) await sdk.updateCustomer(code, { metadata: ownershipMetadata(reference, existingMetadata) });
await store.saveCustomer(reference.type, reference.id, code, reference.email);
return {
customer: existing,
created: false
};
}
const customer = await sdk.createCustomer({
...input.createParams,
email: reference.email,
first_name: reference.name ?? void 0,
metadata: ownershipMetadata(reference)
});
const code = customerCode(customer);
if (code === void 0) return null;
await store.saveCustomer(reference.type, reference.id, code, reference.email);
return {
customer,
created: true
};
}
//#endregion
//#region src/utils.ts
function getPlanSeatAmount(plan) {
if (plan.seatAmount !== void 0) {
if (typeof plan.seatAmount === "number" && Number.isFinite(plan.seatAmount)) return plan.seatAmount;
throw new Error(`Invalid seatAmount for plan '${plan.name}'. Expected a finite number.`);
}
if (plan.seatPriceId === void 0 || plan.seatPriceId === null || plan.seatPriceId === "") return;
const parsed = typeof plan.seatPriceId === "string" ? Number(plan.seatPriceId) : plan.seatPriceId;
if (typeof parsed === "number" && Number.isFinite(parsed)) return parsed;
throw new Error(`Invalid seatPriceId for plan '${plan.name}'. Expected a numeric amount in the smallest currency unit.`);
}
function calculatePlanAmount(plan, quantity) {
return (plan.amount ?? 0) + quantity * (getPlanSeatAmount(plan) ?? 0);
}
function normalizeSubscriptionGroup(group) {
const normalized = group?.trim().toLowerCase();
return normalized === void 0 || normalized === "" ? null : normalized;
}
function isLocalSubscriptionCode(subscriptionCode) {
return typeof subscriptionCode === "string" && (subscriptionCode.startsWith("LOC_") || subscriptionCode.startsWith("sub_local_"));
}
function isLocallyManagedSubscription(subscription) {
if (isLocalSubscriptionCode(subscription.subscriptionCode)) return true;
if (typeof subscription.subscriptionCode === "string" && subscription.subscriptionCode !== "") return false;
return subscription.planCode === void 0 || subscription.planCode === null || subscription.planCode === "";
}
function assertLocallyManagedSubscription(subscription, action) {
if (!isLocallyManagedSubscription(subscription)) throw new Error(`Paystack-managed subscriptions do not support ${action}. Use local billing for seat-based or prorated subscription changes.`);
}
async function getPlans(subscriptionOptions) {
if (subscriptionOptions?.enabled === true) return typeof subscriptionOptions.plans === "function" ? subscriptionOptions.plans() : subscriptionOptions.plans;
throw new Error("Subscriptions are not enabled in the Paystack options.");
}
async function getPlanByName(options, name) {
if (typeof name !== "string" || name.trim() === "") return null;
if (options.subscription?.enabled === true) {
const plans = await getPlans(options.subscription);
const normalizedName = name.toLowerCase();
return plans.find((plan) => typeof plan.name === "string" && plan.name.toLowerCase() === normalizedName) ?? null;
}
return null;
}
async function getProducts(productOptions) {
if (productOptions?.products) return typeof productOptions.products === "function" ? await productOptions.products() : productOptions.products;
return [];
}
async function getProductByName(options, name) {
return await getProducts(options.products).then((products) => products !== void 0 && products !== null ? products.find((product) => product.name.toLowerCase() === name.toLowerCase()) ?? null : null);
}
function getNextPeriodEnd(startDate, interval) {
const date = new Date(startDate);
switch (interval) {
case "daily":
date.setDate(date.getDate() + 1);
break;
case "weekly":
date.setDate(date.getDate() + 7);
break;
case "monthly":
date.setMonth(date.getMonth() + 1);
break;
case "quarterly":
date.setMonth(date.getMonth() + 3);
break;
case "biannually":
date.setMonth(date.getMonth() + 6);
break;
case "annually":
date.setFullYear(date.getFullYear() + 1);
break;
default: date.setMonth(date.getMonth() + 1);
}
return date;
}
/**
* Validates if the amount meets Paystack's minimum transaction requirements.
* Amounts should be in the smallest currency unit (e.g., kobo, cents).
*/
function validateMinAmount(amount, currency) {
const min = {
NGN: 5e3,
GHS: 10,
ZAR: 100,
KES: 300,
USD: 200,
XOF: 100
}[currency.toUpperCase()];
return min !== void 0 ? amount >= min : true;
}
async function syncProductQuantityFromPaystack(ctx, productName, paystackClient) {
const store = createBillingStore(ctx);
let localProduct = await store.findProductByName(productName);
localProduct ??= await store.findProductBySlug(productName.toLowerCase().replace(/\s+/g, "-"));
if (localProduct?.paystackId === void 0 || localProduct.paystackId === null || localProduct.paystackId === "") {
if (localProduct?.id !== void 0 && localProduct.unlimited !== true && typeof localProduct.quantity === "number" && localProduct.quantity > 0) await store.updateProduct(localProduct.id, {
quantity: localProduct.quantity - 1,
updatedAt: /* @__PURE__ */ new Date()
});
return;
}
try {
const paystackProductId = Number(localProduct.paystackId);
if (!Number.isFinite(paystackProductId)) return;
const remoteQuantity = (await createPaystackAdapter(paystackClient).fetchProduct(paystackProductId))?.quantity;
if (remoteQuantity !== void 0 && localProduct.id !== void 0) await store.updateProduct(localProduct.id, {
quantity: remoteQuantity,
updatedAt: /* @__PURE__ */ new Date()
});
} catch {
if (localProduct?.id !== void 0 && localProduct.unlimited !== true && typeof localProduct.quantity === "number" && localProduct.quantity > 0) await store.updateProduct(localProduct.id, {
quantity: localProduct.quantity - 1,
updatedAt: /* @__PURE__ */ new Date()
});
}
}
async function syncSubscriptionSeats(ctx, organizationId, options) {
if (options.subscription?.enabled !== true) return;
const store = createBillingStore(ctx);
const subscriptions = (await store.findSubscriptionsByReference(organizationId)).filter((subscription) => subscription.status === "active" || subscription.status === "trialing");
const seatSubscriptions = [];
for (const candidate of subscriptions) {
const candidatePlan = await getPlanByName(options, candidate.plan);
if (candidatePlan !== null && getPlanSeatAmount(candidatePlan) !== void 0) seatSubscriptions.push(candidate);
}
const quantity = (await store.listMembers(organizationId)).length;
for (const subscription of seatSubscriptions) {
if (subscription.subscriptionCode === void 0 || subscription.subscriptionCode === null || subscription.subscriptionCode === "") continue;
try {
assertLocallyManagedSubscription(subscription, "automatic seat sync");
await store.updateSubscription(subscription.id, {
seats: quantity,
updatedAt: /* @__PURE__ */ new Date()
});
} catch (e) {
ctx.context.logger.error("Failed to sync subscription seats", e);
}
}
}
//#endregion
//#region src/limits.ts
const getOrganizationSubscription = async (ctx, organizationId, groupId) => {
return createBillingStore(ctx).findCurrentSubscription(organizationId, groupId);
};
const checkSeatLimit = async (ctx, organizationId, seatsToAdd = 1) => {
const store = createBillingStore(ctx);
const seatLimit = (await store.findSubscriptionsByReference(organizationId)).filter((subscription) => subscription.status === "active" || subscription.status === "trialing").reduce((maximum, subscription) => typeof subscription.seats === "number" ? Math.max(maximum ?? subscription.seats, subscription.seats) : maximum, void 0);
const members = await store.listMembers(organizationId);
if (seatLimit === void 0) return true;
if (members.length + seatsToAdd > seatLimit) throw new APIError("FORBIDDEN", { message: `Organization member limit reached. Used: ${members.length}, Max: ${seatLimit}` });
return true;
};
async function getOrganizationEntitlements(ctx, organizationId, options) {
const subscriptions = (await createBillingStore(ctx).findSubscriptionsByReference(organizationId)).filter((subscription) => subscription.status === "active" || subscription.status === "trialing");
const limits = {};
const features = /* @__PURE__ */ new Set();
for (const subscription of subscriptions) {
const plan = await getPlanByName(options, subscription.plan);
for (const [name, value] of Object.entries(plan?.limits ?? {})) if (typeof value === "number" && Number.isFinite(value)) limits[name] = Math.max(limits[name] ?? value, value);
for (const feature of plan?.features ?? []) features.add(feature);
}
return {
limits,
features: [...features]
};
}
const checkTeamLimit = async (ctx, organizationId, maxTeams) => {
if ((await createBillingStore(ctx).listTeams(organizationId)).length >= maxTeams) throw new APIError("FORBIDDEN", { message: `Organization team limit reached. Max teams: ${maxTeams}` });
return true;
};
//#endregion
//#region src/credential-crypto.ts
const ALGORITHM = "aes-256-gcm";
const VERSION = "v1";
const IV_LENGTH = 12;
function deriveKey(secret) {
if (typeof secret !== "string" || secret.trim() === "") throw new Error("A non-empty credential encryption key is required");
return createHash("sha256").update(secret, "utf8").digest();
}
function encryptPaystackCredential(value, secret) {
if (value === "") throw new Error("Cannot encrypt an empty Paystack credential");
const iv = randomBytes(IV_LENGTH);
const cipher = createCipheriv(ALGORITHM, deriveKey(secret), iv);
const ciphertext = Buffer.concat([cipher.update(value, "utf8"), cipher.final()]);
const authTag = cipher.getAuthTag();
return [
VERSION,
iv.toString("hex"),
authTag.toString("hex"),
ciphertext.toString("hex")
].join(":");
}
function decryptPaystackCredential(encrypted, secret) {
const parts = encrypted.split(":");
if (parts.length !== 4 || parts[0] !== VERSION) throw new Error("Invalid Paystack credential ciphertext");
const [, ivHex, authTagHex, ciphertextHex] = parts;
const iv = Buffer.from(ivHex, "hex");
const authTag = Buffer.from(authTagHex, "hex");
const ciphertext = Buffer.from(ciphertextHex, "hex");
if (iv.length !== IV_LENGTH || authTag.length !== 16 || ciphertext.length === 0) throw new Error("Invalid Paystack credential ciphertext");
const decipher = createDecipheriv(ALGORITHM, deriveKey(secret), iv);
decipher.setAuthTag(authTag);
return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("utf8");
}
function resolveCredentialEncryptionKey(options) {
const key = options.credentialEncryptionKey ?? options.secretKey;
if (typeof key !== "string" || key.trim() === "") throw new Error("credentialEncryptionKey or secretKey must be a non-empty string");
return key;
}
//#endregion
//#region src/payment-credentials.ts
async function readPaystackPaymentCredentials(adapter, options, subscriptionId) {
let row;
try {
row = await adapter.findOne({
model: PAYSTACK_MODELS.paymentCredential,
where: [{
field: "subscriptionId",
value: subscriptionId
}]
});
} catch {
return null;
}
if (!row) return null;
const key = resolveCredentialEncryptionKey(options);
const authorizationCodeEncrypted = row.authorizationCodeEncrypted;
const emailTokenEncrypted = row.emailTokenEncrypted;
return {
authorizationCode: typeof authorizationCodeEncrypted === "string" && authorizationCodeEncrypted !== "" ? decryptPaystackCredential(authorizationCodeEncrypted, key) : void 0,
emailToken: typeof emailTokenEncrypted === "string" && emailTokenEncrypted !== "" ? decryptPaystackCredential(emailTokenEncrypted, key) : void 0
};
}
async function savePaystackPaymentCredentials(adapter, options, subscriptionId, values) {
const authorizationCode = values.authorizationCode ?? void 0;
const emailToken = values.emailToken ?? void 0;
if (authorizationCode === void 0 && emailToken === void 0) return;
const key = resolveCredentialEncryptionKey(options);
const now = /* @__PURE__ */ new Date();
let existing = null;
try {
existing = await adapter.findOne({
model: PAYSTACK_MODELS.paymentCredential,
where: [{
field: "subscriptionId",
value: subscriptionId
}]
});
} catch {}
const update = {
...authorizationCode !== void 0 ? { authorizationCodeEncrypted: encryptPaystackCredential(authorizationCode, key) } : {},
...emailToken !== void 0 ? { emailTokenEncrypted: encryptPaystackCredential(emailToken, key) } : {},
updatedAt: now
};
if (existing !== null) {
await adapter.update({
model: PAYSTACK_MODELS.paymentCredential,
update,
where: [{
field: "id",
value: existing.id
}]
});
return;
}
await adapter.create({
model: PAYSTACK_MODELS.paymentCredential,
data: {
subscriptionId,
...update,
createdAt: now
}
});
}
//#endregion
//#region src/reference-access.ts
const DEFAULT_BILLING_ORG_ROLES = ["owner", "admin"];
function normalizeBillingRoles(roles) {
return new Set(roles.map((value) => value.trim()).filter((value) => value !== ""));
}
function getBillingRoles(options) {
return options.organization?.billingRoles ?? DEFAULT_BILLING_ORG_ROLES;
}
function hasBillingRole(role, billingRoles = DEFAULT_BILLING_ORG_ROLES) {
const allowedRoles = normalizeBillingRoles(billingRoles);
if (Array.isArray(role)) return role.some((value) => hasBillingRole(value, billingRoles));
if (typeof role !== "string") return false;
return role.split(",").map((value) => value.trim()).some((value) => allowedRoles.has(value));
}
function resolveBillingReferenceId(input) {
const body = input.body ?? {};
const query = input.query ?? {};
const requestQueryReferenceId = typeof input.requestUrl === "string" ? new URL(input.requestUrl).searchParams.get("referenceId") ?? void 0 : void 0;
return body.referenceId ?? query.referenceId ?? requestQueryReferenceId ?? input.fallbackUserId;
}
async function authorizeBillingReference(ctx, options, data) {
if (data.referenceId === data.user.id) return;
if (options.subscription?.enabled === true && typeof options.subscription.authorizeReference === "function") {
if (await options.subscription.authorizeReference({
user: data.user,
session: data.session,
referenceId: data.referenceId,
action: data.action
}, ctx) === true) return;
throw new APIError("UNAUTHORIZED");
}
if (options.organization?.enabled === true) {
const member = await ctx.context.adapter.findOne({
model: "member",
where: [{
field: "userId",
value: data.user.id
}, {
field: "organizationId",
value: data.referenceId
}]
});
if (member !== null && member !== void 0 && hasBillingRole(member.role, getBillingRoles(options))) return;
}
throw new APIError("UNAUTHORIZED");
}
//#endregion
//#region src/subscription-fulfillment.ts
const progressSchema = z$1.object({
version: z$1.literal(1),
completed: z$1.array(z$1.enum([
"complete",
"update",
"trialStart"
])),
remoteCreationSubmitted: z$1.boolean().optional(),
remoteSubscriptionCode: z$1.string().optional()
});
const unavailable = () => new APIError("SERVICE_UNAVAILABLE", { message: "Subscription fulfillment is pending. Retry verification." });
/** Persist delivery intent before activation, sharing one lease across verification transports. */
async function claimSubscriptionFulfillment(ctx, reference, subscription, phase = "subscription-fulfillment") {
const store = createBillingStore(ctx);
const eventId = `${phase}:${createHash("sha256").update(JSON.stringify([reference, subscription.id])).digest("hex")}`;
const findRecord = () => ctx.context.adapter.findOne({
model: PAYSTACK_MODELS.webhookEvent,
where: [{
field: "eventId",
value: eventId
}]
});
let record = await findRecord();
if (record === null) {
if (subscription.status !== "incomplete") return null;
try {
record = await store.createWebhookEvent({
eventId,
eventType: "subscription.fulfillment",
reference,
status: "pending",
payload: JSON.stringify({
version: 1,
completed: []
}),
createdAt: /* @__PURE__ */ new Date(),
updatedAt: /* @__PURE__ */ new Date()
});
} catch (error) {
record = await findRecord();
if (record === null) throw error;
}
}
if (record.status === "processed") return null;
if (record.status.startsWith("processing:") && Date.now() - new Date(record.updatedAt).getTime() < 3e5) throw unavailable();
const progress = progressSchema.parse(JSON.parse(record.payload));
const status = `processing:${randomUUID()}`;
if (await ctx.context.adapter.update({
model: PAYSTACK_MODELS.webhookEvent,
where: [
{
field: "eventId",
value: eventId
},
{
field: "status",
value: record.status
},
{
field: "updatedAt",
value: record.updatedAt
}
],
update: {
status,
updatedAt: /* @__PURE__ */ new Date()
}
}) === null) throw unavailable();
return {
eventId,
status,
progress
};
}
async function deliverTrialStart(ctx, claim, subscription, plan) {
if (!claim.progress.completed.includes("trialStart")) {
await checkpoint(ctx, claim);
await plan?.freeTrial?.onTrialStart?.(subscription);
claim.progress.completed.push("trialStart");
await checkpoint(ctx, claim);
}
await checkpoint(ctx, claim, true);
}
async function reserveRemoteTrialSubscription(ctx, claim, create) {
if (claim.progress.remoteSubscriptionCode !== void 0) return claim.progress.remoteSubscriptionCode;
if (claim.progress.remoteCreationSubmitted === true) throw new APIError("SERVICE_UNAVAILABLE", { message: "Remote trial subscription creation is unresolved. Reconcile the existing provider subscription before retrying." });
claim.progress.remoteCreationSubmitted = true;
await checkpoint(ctx, claim);
const code = await create();
if (code === void 0 || code === "") throw unavailable();
claim.progress.remoteSubscriptionCode = code;
await checkpoint(ctx, claim);
return code;
}
async function checkpoint(ctx, claim, processed = false) {
if (await ctx.context.adapter.update({
model: PAYSTACK_MODELS.webhookEvent,
where: [{
field: "eventId",
value: claim.eventId
}, {
field: "status",
value: claim.status
}],
update: {
payload: JSON.stringify(claim.progress),
updatedAt: /* @__PURE__ */ new Date(),
...processed ? {
status: "processed",
processedAt: /* @__PURE__ */ new Date()
} : {}
}
}) === null) throw unavailable();
}
async function deliverSubscriptionFulfillment(ctx, options, claim, subscription, plan, event) {
if (plan !== void 0) {
const callbacks = {
complete: options.subscription?.onSubscriptionComplete,
update: options.subscription?.onSubscriptionUpdate
};
for (const name of ["complete", "update"]) {
if (claim.progress.completed.includes(name)) continue;
await checkpoint(ctx, claim);
await callbacks[name]?.({
event,
subscription,
plan
}, ctx);
claim.progress.completed.push(name);
await checkpoint(ctx, claim);
}
}
await checkpoint(ctx, claim, true);
}
async function releaseSubscriptionFulfillment(ctx, claim) {
await ctx.context.adapter.update({
model: PAYSTACK_MODELS.webhookEvent,
where: [{
field: "eventId",
value: claim.eventId
}, {
field: "status",
value: claim.status
}],
update: {
status: "pending",
updatedAt: /* @__PURE__ */ new Date()
}
});
}
//#endregion
//#region src/reconciliation.ts
function getAllowedSubscriptionChannels$1(options) {
const channels = options.subscription?.allowedPaymentChannels;
return Array.isArray(channels) && channels.length > 0 ? channels : void 0;
}
function isAllowedSubscriptionChannel(channel, allowedChannels) {
if (allowedChannels === void 0) return true;
return channel !== void 0 && channel !== null && allowedChannels.includes(channel);
}
function createSummary() {
return {
transaction: {
found: false,
updated: false
},
subscription: {
found: false,
updated: false,
prorationApplied: false
},
customer: { saved: false },
product: { synced: false }
};
}
function getErrorMessage(error, fallback) {
return error instanceof Error && error.message !== "" ? error.message : fallback;
}
function createFailureResult(input) {
return {
ok: false,
source: input.source,
status: input.status,
reference: input.reference,
data: input.data,
error: input.error,
...input.summary
};
}
function throwOrReturnFailure(input) {
if (input.throwOnError) throw new APIError(input.apiStatus, {
code: input.error.code,
message: input.error.message,
status: input.error.status
});
return createFailureResult(input);
}
function hasReferenceMismatch(input) {
return input.expectedReferenceId !== void 0 && input.expectedReferenceId !== "" && input.transactionReferenceId !== void 0 && input.transactionReferenceId !== null && input.transactionReferenceId !== "" && input.expectedReferenceId !== input.transactionReferenceId;
}
function getNonEmptyString(value) {
return typeof value === "string" && value !== "" ? value : void 0;
}
async function reconcilePaystackTransaction(ctx, options, input) {
const source = input.source ?? "server";
const throwOnError = input.throwOnError === true;
const summary = createSummary();
const paystack = getPaystackOps(options.paystackClient);
let data;
try {
data = unwrapSdkResult(await paystack?.transaction?.verify(input.reference));
} catch (error) {
ctx.context.logger.error("Failed to verify Paystack transaction", error);
return throwOrReturnFailure({
throwOnError,
apiStatus: "BAD_REQUEST",
source,
status: "error",
reference: input.reference,
data: null,
summary,
error: {
code: "FAILED_TO_VERIFY_TRANSACTION",
message: getErrorMessage(error, "Failed to verify transaction"),
status: 400
}
});
}
if (data === void 0 || data === null) return throwOrReturnFailure({
throwOnError,
apiStatus: "BAD_REQUEST",
source,
status: "error",
reference: input.reference,
data: null,
summary,
error: {
code: "FAILED_TO_VERIFY_TRANSACTION",
message: "Failed to fetch transaction data from Paystack.",
status: 400
}
});
const status = data.status ?? "failed";
const reference = input.reference;
const paystackIdRaw = data.id;
const paystackId = paystackIdRaw !== void 0 && paystackIdRaw !== null ? String(paystackIdRaw) : void 0;
const authorizationCode = data.authorization?.authorization_code;
const store = createBillingStore(ctx);
const txRecord = await store.findTransactionByReference(reference);
summary.transaction.found = txRecord !== null;
summary.transaction.previousStatus = txRecord?.status;
if (hasReferenceMismatch({
expectedReferenceId: input.referenceId,
transactionReferenceId: txRecord?.referenceId
})) return throwOrReturnFailure({
throwOnError,
apiStatus: "UNAUTHORIZED",
source,
status,
reference,
data,
summary,
error: {
code: "REFERENCE_ID_MISMATCH",
message: "Transaction reference does not belong to the expected billing reference.",
status: 401
}
});
const referenceId = input.referenceId ?? getNonEmptyString(txRecord?.referenceId) ?? getNonEmptyString(input.actor?.user.id);
summary.transaction.referenceId = referenceId;
if (input.actor !== void 0 && referenceId !== void 0 && referenceId !== input.actor.user.id) try {
await authorizeBillingReference(ctx, options, {
user: input.actor.user,
session: input.actor.session,
referenceId,
action: "verify-transaction"
});
} catch (error) {
return throwOrReturnFailure({
throwOnError,
apiStatus: "UNAUTHORIZED",
source,
status,
reference,
data,
summary,
error: {
code: "UNAUTHORIZED",
message: getErrorMessage(error, "Not authorized to reconcile this transaction."),
status: 401
}
});
}
if (data.reference !== reference || txRecord !== null && typeof txRecord.amount === "number" && txRecord.amount !== data.amount || txRecord !== null && typeof txRecord.currency === "string" && txRecord.currency !== data.currency) return throwOrReturnFailure({
throwOnError,
apiStatus: "BAD_REQUEST",
source,
status,
reference,
data,
summary,
error: {
code: "PAYMENT_INTENT_MISMATCH",
message: "Verified payment does not match the stored reference, amount, and currency.",
status: 400
}
});
const transactionUpdate = {
status,
paystackId,
amount: data.amount,
currency: data.currency,
updatedAt: /* @__PURE__ */ new Date()
};
const updatedTransaction = await store.updateTransactionByReference(reference, transactionUpdate);
summary.transaction.updated = updatedTransaction !== null;
summary.transaction.status = status;
if (status !== "success") return {
ok: true,
source,
status,
reference,
data,
...summary
};
const allowedSubscriptionChannels = getAllowedSubscriptionChannels$1(options);
if ((txRecord?.plan !== void 0 && txRecord.plan !== null && txRecord.plan !== "" || Boolean(data.plan)) && isAllowedSubscriptionChannel(data.channel ?? void 0, allowedSubscriptionChannels) === false) {
await store.updateTransactionByReference(reference, {
...transactionUpdate,
status: "failed"
});
summary.transaction.updated = true;
summary.transaction.status = "failed";
return throwOrReturnFailure({
throwOnError,
apiStatus: "BAD_REQUEST",
source,
status: "failed",
reference,
data,
summary,
error: {
code: "SUBSCRIPTION_PAYMENT_CHANNEL_NOT_ALLOWED",
message: `This subscription requires one of: ${allowedSubscriptionChannels?.join(", ") ?? "allowed channels"}.`,
status: 400
}
});
}
const paystackCustomerCodeFromPaystack = data.customer?.customer_code;
if (paystackCustomerCodeFromPaystack !== void 0 && paystackCustomerCodeFromPaystack !== null && paystackCustomerCodeFromPaystack !== "" && referenceId !== void 0 && referenceId !== "") {
let isOrganization = options.organization?.enabled === true && typeof referenceId === "string" && referenceId.startsWith("org_");
if (isOrganization === false && options.organization?.enabled === true) isOrganization = await store.findOrganization(referenceId) !== null;
await store.saveCustomerCode(referenceId, paystackCustomerCodeFromPaystack, isOrganization, data.customer?.email);
summary.customer.saved = true;
summary.customer.referenceId = referenceId;
summary.customer.customerCode = paystackCustomerCodeFromPaystack;
summary.customer.model = isOrganization ? "organization" : "user";
}
const transaction = updatedTransaction ?? await store.findTransactionByReference(reference);
if (transaction !== void 0 && transaction !== null && transaction.product !== void 0 && transaction.product !== null && transaction.product !== "" && options.paystackClient !== void 0 && options.paystackClient !== null) {
await syncProductQuantityFromPaystack(ctx, transaction.product, options.paystackClient);
summary.product.synced = true;
summary.product.name = transaction.product;
}
if (options.subscription?.enabled !== true) return {
ok: true,
source,
status,
reference,
data,
...summary
};
const metadataObj = parsePaystackMetadata(data.metadata);
const isTrial = getMetadataBoolean(metadataObj, "isTrial");
const trialEnd = getMetadataString(metadataObj, "trialEnd");
const targetPlan = getMetadataString(metadataObj, "plan");
if (metadataObj.type === "proration") {
const subscriptionId = getMetadataString(metadataObj, "subscriptionId");
const newPlan = getMetadataString(metadataObj, "newPlan");
const newSeatCount = getMetadataNumber(metadataObj, "newSeatCount");
if (subscriptionId !== void 0 && subscriptionId !== "" && newPlan !== void 0 && newPlan !== "") {
const originalSubscription = await store.findSubscriptionById(subscriptionId);
if (!originalSubscription || originalSubscription.status === "canceled" || originalSubscription.transactionReference === reference) return {
ok: true,
source,
status,
reference,
data,
...summary
};
const updatedSubscription = await store.updateSubscription(subscriptionId, {
plan: newPlan,
...typeof newSeatCount === "number" ? { seats: newSeatCount } : {},
transactionReference: reference,
updatedAt: /* @__PURE__ */ new Date()
});
summary.subscription.found = updatedSubscription !== null;
summary.subscription.updated = updatedSubscription !== null;
summary.subscription.id = updatedSubscription?.id ?? subscriptionId;
summary.subscription.status = updatedSubscription?.status;
summary.subscription.prorationApplied = updatedSubscription !== null;
if (updatedSubscription !== null && authorizationCode !== void 0 && authorizationCode !== null && authorizationCode !== "") await savePaystackPaymentCredentials(ctx.context.adapter, options, updatedSubscription.id, { authorizationCode });
}
return {
ok: true,
source,
status,
reference,
data,
...summary
};
}
let subscriptionCode;
const targetSub = (await store.findSubscriptionsByTransactionReference(reference)).find((subscription) => referenceId === void 0 || referenceId === "" || subscription.referenceId === referenceId);
summary.subscription.found = targetSub !== void 0;
summary.subscription.id = targetSub?.id;
summary.subscription.status = targetSub?.status;
if (!targetSub || ![
"incomplete",
"active",
"trialing"
].includes(targetSub.status)) return {
ok: true,
source,
status,
reference,
data,
...summary
};
const claim = await claimSubscriptionFulfillment(ctx, reference, targetSub);
if (claim === null) return {
ok: true,
source,
status,
reference,
data,
...summary
};
try {
if (targetSub.status !== "incomplete") {
if (authorizationCode !== void 0 && authorizationCode !== null && authorizationCode !== "") await savePaystackPaymentCredentials(ctx.context.adapter, options, targetSub.id, { authorizationCode });
await store.retireCompetingSubscriptions(targetSub.referenceId, targetSub.groupId ?? null, targetSub.id);
await deliverSubscriptionFulfillment(ctx, options, claim, targetSub, (await getPlans(options.subscription)).find((candidate) => candidate.name.toLowerCase() === targetSub.plan.toLowerCase()), data);
return {
ok: true,
source,
status,
reference,
data,
...summary
};
}
if (isTrial && targetPlan !== void 0 && trialEnd !== void 0) {
const email = data.customer?.email;
const planConfig = (await getPlans(options.subscription)).find((plan) => plan.name.toLowerCase() === targetPlan.toLowerCase());
if (planConfig !== void 0 && planConfig !== null && (planConfig.planCode === void 0 || planConfig.planCode === null || planConfig.planCode === "")) subscriptionCode = `LOC_${reference}`;
else if (targetSub?.subscriptionCode !== void 0 && targetSub.subscriptionCode !== null && targetSub.subscriptionCode !== "") subscriptionCode = targetSub.subscriptionCode;
else if (authorizationCode !== void 0 && authorizationCode !== null && email !== void 0 && email !== null && email !== "" && planConfig?.planCode !== void 0 && planConfig.planCode !== null && planConfig.planCode !== "") {
const planCode = planConfig.planCode;
subscriptionCode = await reserveRemoteTrialSubscription(ctx, claim, async () => {
return unwrapSdkResult(await paystack?.subscription?.create({ body: {
customer: email,
plan: planCode,
authorization: authorizationCode,
start_date: trialEnd
} }))?.subscription_code;
});
}
} else if (isTrial === false) {
const planCodeFromPaystack = data.plan?.plan_code;
if (planCodeFromPaystack === void 0 || planCodeFromPaystack === null || planCodeFromPaystack === "") subscriptionCode = `LOC_${reference}`;
else subscriptionCode = data.subscription?.subscription_code ?? void 0;
}
let updatedSubscription = null;
if (targetSub !== void 0 && targetSub !== null) {
const resolvedPlan = (await getPlans(options.subscription)).find((candidate) => candidate.name.toLowerCase() === targetSub.plan.toLowerCase());
updatedSubscription = await ctx.context.adapter.update({
model: PAYSTACK_MODELS.subscription,
where: [
{
field: "id",
value: targetSub.id
},
{
field: "status",
value: "incomplete"
},
{
field: "transactionReference",
value: reference
}
],
update: {
status: isTrial ? "trialing" : "active",
billingInterval: resolvedPlan?.interval ?? targetSub.billingInterval ?? null,
periodStart: /* @__PURE__ */ new Date(),
updatedAt: /* @__PURE__ */ new Date(),
...isTrial && trialEnd !== void 0 ? {
trialStart: /* @__PURE__ */ new Date(),
trialEnd: new Date(trialEnd),
periodEnd: new Date(trialEnd)
} : {},
...subscriptionCode !== void 0 ? { subscriptionCode } : {}
}
});
summary.subscription.updated = updatedSubscription !== null;
summary.subscription.id = updatedSubscription?.id ?? targetSub.id;
summary.subscription.status = updatedSubscription?.status ?? targetSub.status;
if (authorizationCode !== void 0 && authorizationCode !== null && authorizationCode !== "" && updatedSubscription !== null) await savePaystackPaymentCredentials(ctx.context.adapter, options, updatedSubscription.id, { authorizationCode });
if (updatedSubscription !== null && (updatedSubscription.status === "active" || updatedSubscription.status === "trialing")) await store.retireCompetingSubscriptions(updatedSubscription.referenceId, updatedSubscription.groupId ?? null, updatedSubscription.id);
}
if (updatedSubscription !== void 0 && updatedSubscription !== null) {
const plan = (await getPlans(options.subscription)).find((candidate) => candidate.name.toLowerCase() === updatedSubscription.plan.toLowerCase());
await deliverSubscriptionFulfillment(ctx, options, claim, updatedSubscription, plan, data);
} else throw new APIError("SERVICE_UNAVAILABLE", { message: "Subscription activation is pending. Retry verification." });
return {
ok: true,
source,
status,
reference,
data,
...summary
};
} catch (error) {
await releaseSubscriptionFulfillment(ctx, claim);
throw error;
}
}
//#endregion
//#region src/reference-middleware.ts
const referenceMiddleware = (options, action) => createAuthMiddleware(async (ctx) => {
const session = ctx.context.session;
if (session === null || session === void 0) throw new APIError("UNAUTHORIZED");
const referenceId = resolveBillingReferenceId({
body: ctx.body,
query: ctx.query,
requestUrl: ctx.request?.url,
fallbackUserId: session.user.id
});
await authorizeBillingReference(ctx, options, {
user: session.user,
session: session.session,
referenceId,
action
});
return { context: {
...ctx.context,
referenceId
} };
});
//#endregion
//#region src/route-modules/callback-url.ts
/** Accept only absolute HTTP(S) URLs or root-relative paths on the Better Auth origin. */
function hasControlCharacters(value) {
for (const character of value) {
const codePoint = character.codePointAt(0);
if (codePoint !== void 0 && (codePoint <= 31 || codePoint >= 127 && codePoint <= 159)) return true;
}
return false;
}
function isTrustedCallbackURL(callbackURL, baseURL) {
if (callbackURL.startsWith("//") || callbackURL.includes("\\") || hasControlCharacters(callbackURL)) return false;
try {
const base = new URL(baseURL);
if (!/^https?:\/\//i.test(callbackURL) && !callbackURL.startsWith("/")) return false;
const callback = new URL(callbackURL, base);
return (callback.protocol === "http:" || callback.protocol === "https:") && callback.origin === base.origin && callback.username === "" && callback.password === "";
} catch {
return false;
}
}
//#endregion
//#region src/route-modules/catalog.ts
async function listStoredProducts(ctx) {
return createBillingStore(ctx).listProducts();
}
async function listStoredPlans(ctx) {
return createBillingStore(ctx).listPlans();
}
async function getConfiguredCatalog(options) {
return {
plans: options.subscription?.enabled === true ? await getPlans(options.subscription) : [],
products: await getProducts(options.products)
};
}
//#endregion
//#region src/route-modules/checkout.ts
const initializeTransactionBodySchema = z.object({
plan: z.string().optional(),
product: z.string().optional(),
amount: z.number().int().positive().optional(),
currency: z.string().optional(),
email: z.string().optional(),
metadata: z.record(z.string(), z.unknown()).optional(),
referenceId: z.string().optional(),
subscriptionId: z.string().optional(),
callbackURL: z.string().optional(),
quantity: z.number().int().positive().optional(),
scheduleAtPeriodEnd: z.boolean().optional(),
cancelAtPeriodEnd: z.boolean().optional(),
prorateAndCharge: z.boolean().optional(),
confirmTrialTokenizationCharge: z.boolean().optional()
});
//#endregion
//#region src/route-modules/shared.ts
const PAYSTACK_ERROR_CODES = defineErrorCodes({
SUBSCRIPTION_NOT_FOUND: "Subscription not found",
SUBSCRIPTION_PLAN_NOT_FOUND: "Subscription plan not found",
UNABLE_TO_CREATE_CUSTOMER: "Unable to create customer",
FAILED_TO_INITIALIZE_TRANSACTION: "Failed to initialize transaction",
FAILED_TO_VERIFY_TRANSACTION: "Failed to verify transaction",
FAILED_TO_DISABLE_SUBSCRIPTION: "Failed to disable subscription",
FAILED_TO_ENABLE_SUBSCRIPTION: "Failed to enable subscription",
EMAIL_VERIFICATION_REQUIRED: "Email verification is required before you can subscribe to a plan",
SUBSCRIPTION_PAYMENT_CHANNEL_NOT_ALLOWED: "This subscription only supports specific payment channels"
});
function getAllowedSubscriptionChannels(options) {
const channels = options.subscription?.allowedPaymentChannels;
return Array.isArray(channels) && channels.length > 0 ? channels : void 0;
}
async function hmacSha512Hex(secret, message) {
const encoder = new TextEncoder();
const keyData = encoder.encode(secret);
const msgData = encoder.encode(message);
const crypto = globalThis.crypto;
if (crypto !== void 0 && crypto !== null && "subtle" in crypto) {
const subtle = crypto.subtle;
const key = await subtle.importKey("raw", keyData, {
name: "HMAC",
hash: "SHA-512"
}, false, ["sign"]);
const signature = await subtle.sign("HMAC", key, msgData);
return Array.from(new Uint8Array(signature)).map((b) => b.toString(16).padStart(2, "0")).join("");
}
const { createHmac } = await import("node:crypto");
return createHmac("sha512", secret).update(message).digest("hex");
}
//#endregion
//#region src/route-modules/subscriptions.ts
const enableDisableBodySchema = z.object({
referenceId: z.string().optional(),
subscriptionCode: z.string(),
emailToken: z.string().optional(),
atPeriodEnd: z.boolean().optional()
});
function decodeBase64UrlToString(value) {
const normalized = value.replace(/-/g, "+").replace(/_/g, "/");
const padded = normalized + "===".slice((normalized.length + 3) % 4);
const binaryString = atob(padded);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) bytes[i] = binaryString.charCodeAt(i);
return new TextDecoder().decode(bytes);
}
function tryGetEmailTokenFromSubscriptionManageLink(link) {
try {
const subscriptionToken = new URL(link).searchParams.get("subscription_token");
if (subscriptionToken === void 0 || subscriptionToken === null || subscriptionToken === "") return void 0;
const parts = subscriptionToken.split(".");
if (parts.length < 2) return void 0;
const payloadJson = decodeBase64UrlToString(parts[1] ?? "");
const payload = JSON.parse(payloadJson);
return typeof payload.email_token === "string" ? payload.email_token : void 0;
} catch {
return;
}
}
//#endregion
//#region src/route-modules/webhook.ts
function getWebhookRequest(ctx) {
return ctx.requestClone ?? ctx.request;
}
function getWebhookHeaders(ctx) {
return ctx.headers ?? ctx.request?.headers;
}
function getWebhookClientIP(ctx, headers) {
return headers?.get("x-forwarded-for")?.split(",")[0]?.trim() ?? headers?.get("x-real-ip") ?? ctx.request.ip;
}
//#endregion
//#region src/subscription-lifecycle.ts
/** Keep reservation context invocation-local, including concurrent requests through one plugin. */
async function runSubscriptionOperation(ctx, options, operation, run) {
const hooks = options?.subscription?.operationHooks;
if (hooks === void 0) return run(void 0, () => void 0);
const decision = await hooks.before(operation, ctx);
if (decision.kind === "replay") {
if (!(operation.kind === "schedule" ? decision.result.kind === "scheduled" : operation.kind === "initialize" ? decision.result.kind === "checkout" : operation.kind === "local-change" ? decision.result.kind === "prorated" : decision.result.kind === "checkout" || decision.result.kind === "prorated")) throw new APIError("BAD_REQUEST", { message: "Operation replay does not match the validated operation kind." });
return decision.result;
}
if (decision.kind === "block") throw new APIError("CONFLICT", { message: decision.message ?? "Subscription operation is blocked." });
if (decision.kind !== "proceed" || typeof decision.providerReference !== "string" || decision.providerReference.trim() === "") throw new APIError("BAD_REQUEST", { message: "Operation hook must reserve a provider reference." });
const invocation = {
...operation,
providerReference: decision.providerReference,
context: decision.context
};
let phase = operation.kind === "schedule" || operation.kind === "local-change" ? "persistence" : "provider";
try {
const result = await run(decision.providerReference, (next) => {
phase = next;
});
phase = "completion";
await hooks.after?.({
...invocation,
result
}, ctx);
return result;
} catch (error) {
try {
await hooks.onError?.({
...invocation,
phase,
error
}, ctx);
} catch {
ctx.context.logger.error("Paystack operation error hook failed", { phase });
}
throw error;
}
}
async function scheduleSubscriptionLifecycleChange(ctx, input, options) {
const groupId = normalizeSubscriptionGroup(input.plan?.group);
if (input.plan !== void 0 && input.scheduleAtPeriodEnd === true) {
const existingSub = input.subscriptionId === void 0 ? await getOrganizationSubscription(ctx, input.referenceId, groupId) : await createBillingStore(ctx).findSubscriptionById(input.subscriptionId);
if (existingSub?.status === "active") return runSubscriptionOperation(ctx, options, {
actor: { id: input.userId ?? existingSub.userId },
referenceId: input.referenceId,
kind: "schedule",
subscription: existingSub,
intent: {
plan: input.plan?.name ?? existingSub.plan,
interval: input.plan?.interval ?? existingSub.billingInterval ?? void 0,
groupId,
scheduleAtPeriodEnd: input.scheduleAtPeriodEnd,
cancelAtPeriodEnd: input.cancelAtPeriodEnd
}
}, async () => {
await ctx.context.adapter.update({
model: PAYSTACK_MODELS.subscription,
where: [{
field: "id",
value: existingSub.id
}],
update: {
pendingPlan: input.plan?.name,
updatedAt: /* @__PURE__ */ new Date()
}
});
return {
kind: "scheduled",
status: "success",
message: "Plan change scheduled at period end.",
scheduled: true
};
});
}
if (input.cancelAtPeriodEnd === true) {
const existingSub = input.subscriptionId === void 0 ? await getOrganizationSubscription(ctx, input.referenceId, input.plan === void 0 ? void 0 : groupId) : await createBillingStore(ctx).findSubscriptionById(input.subscriptionId);
if (existingSub?.status === "active") return runSubscriptionOperation(ctx, options, {
actor: { id: input.userId ?? existingSub.userId },
referenceId: input.referenceId,
kind: "schedule",
subscription: existingSub,
intent: {
plan: input.plan?.name ?? existingSub.plan,
interval: input.plan?.interval ?? existingSub.billingInterval ?? void 0,
groupId,
scheduleAtPeriodEnd: input.scheduleAtPeriodEnd,
cancelAtPeriodEnd: input.cancelAtPeriodEnd
}
}, async () => {
await ctx.context.adapter.update({
model: PAYSTACK_MODELS.subscription,
where: [{
field: "id",
value: existingSub.id
}],
update: {
cancelAtPeriodEnd: true,
cancelAt: existingSub.periodEnd ?? null,
canceledAt: /* @__PURE__ */ new Date(),
updatedAt: /* @__PURE__ */ new Date()
}
});
return {
kind: "scheduled",
status: "success",
message: "Subscription cancellation scheduled at period end.",
scheduled: true
};
});
}
return null;
}
async function resolveTrialLifecycle(ctx, input) {
const requestedDays = input.plan?.freeTrial?.days !== void 0 && input.plan.freeTrial.days > 0 ? input.plan.freeTrial.days : 0;
const requested = requestedDays > 0;
if (!requested) return {
requestedDays,
requested: false,
granted: false
};
if ((await ctx.context.adapter.findMany({
model: PAYSTACK_MODELS.subscription,
where: [{
field: "referenceId",
value: input.referenceId
}]
}))?.some((subscription) => subscription.trialStart !== void 0 && subscription.trialStart !== null || subscription.trialEnd !== void 0 && subscription.trialEnd !== null || subscription.status === "trialing") === true) return {
requestedDays,
requested,
granted: false,
deniedReason: "already_used"
};
const trialStart = /* @__PURE__ */ new Date();
const trialEnd = /* @__PURE__ */ new Date();
trialEnd.setDate(trialEnd.getDate() + requestedDays);
return {
trialStart,
trialEnd,
requestedDays,
requested,
granted: true
};
}
async function resolveCheckoutTargetEmail(ctx, options, input) {
const targetEmail = input.email ?? input.user.email;
if (options.organization?.enabled !== true || input.referenceId === input.user.id || input.referenceId === "") return targetEmail;
const org = await ctx.context.adapter.findOne({
model: "organization",
where: [{
field: "id",
value: input.referenceId
}]
});
if (org === void 0 || org === null) return targetEmail;
const orgWithEmail = org;
if (orgWithEmail.email !== void 0 && orgWithEmail.email !== null && orgWithEmail.email !== "") return orgWithEmail.email;
const ownerMember = await ctx.context.adapter.findOne({
model: "member",
where: [{
field: "organizationId",
value: input.referenceId
}, {
field: "role",
value: "owner"
}]
});
if (ownerMember === void 0 || ownerMember === null) return targetEmail;
const ownerUser = await ctx.context.adapter.findOne({
model: "user",
where: [{
field: "id",
value: ownerMember.userId
}]
});
return ownerUser?.email !== void 0 && ownerUser.email !== "" ? ownerUser.email : targetEmail;
}
async function handleProratedUpgrade(ctx, options, input) {
const store = createBillingStore(ctx);
const existingSub = input.subscriptionId === void 0 ? await store.findCurrentSubscription(input.referenceId, normalizeSubscriptionGroup(input.plan.group)) : await store.findSubscriptionById(input.subscriptionId);
if (existingSub?.status !== "active" || existingSub.subscriptionCode === void 0 || existingSub.subscriptionCode === null || existingSub.subscriptionCode === "" || existingSub.periodEnd === void 0 || existingSub.periodEnd === null || existingSub.periodStart === void 0 || existingSub.periodStart === null) return null;
const now = /* @__PURE__ */ new Date();
const periodEnd = new Date(existingSub.periodEnd);
const periodStart = new Date(existingSub.periodStart);
const totalDays = Math.max(1, Math.ceil((periodEnd.getTime() - periodStart.getTime()) / 864e5));
const remainingDays = Math.max(0, Math.ceil((periodEnd.getTime() - now.getTime()) / 864e5));
let oldAmount = 0;
if (existingSub.plan !== "") {
const oldPlan = await getPlanByName(options, existingSub.plan) ?? await store.findPlanByName(existingSub.plan);
if (oldPlan !== void 0 && oldPlan !== null) oldAmount = calculatePlanAmount(oldPlan, existingSub.seats ?? 1);
}
let membersCount = 1;
let newSeatCount;
let newAmount;
try {
assertLocallyManagedSubscription(existingSub, "plan or seat changes");
if (getPlanSeatAmount(input.plan) !== void 0) {
const members = await store.listMembers(input.referenceId);
membersCount = members.length > 0 ? members.length : 1;
}
newSeatCount = input.quantity ?? existingSub.seats ?? membersCount;
newAmount = calculatePlanAmount(input.plan, newSeatCount);
} catch (error) {
throw new APIError("BAD_REQUEST", { message: error instanceof Error ? error.message : "Invalid seat configuration for plan." });
}
const costDifference = newAmount - oldAmount;
const prorationMetadata = createProrationMetadata({
subscriptionId: existingSub.id,
referenceId: input.referenceId,
newPlan: input.plan.name.toLowerCase(),
oldPlan: existingSub.plan,
newSeatCount,
remainingDays
});
const serializedProrationMetadata = stringifyPaystackMetadata(prorationMetadata);
const proratedAmount = Math.round(costDifference / totalDays * remainingDays);
const hasCharge = costDifference > 0 && remainingDays > 0;
if (hasCharge && proratedAmount < 5e3) throw new APIError("BAD_REQUEST", {
message: "Prorated upgrade amount is below Paystack's minimum charge. Schedule the change for period end instead.",
status: 400
});
const credentials = hasCharge ? await readPaystackPaymentCredentials(ctx.context.adapter, options, existingSub.id) : null;
const hasAuthorization = credentials?.authorizationCode !== void 0 && credentials.authorizationCode !== null && credentials.authorizationCode !== "";
return runSubscriptionOperation(ctx, options, {
actor: { id: input.userId ?? existingSub.userId },
referenceId: input.referenceId,
kind: hasCharge ? hasAuthorization ? "proration-charge" : "proration-initialize" : "local-change",
subscription: existingSub,
intent: {
plan: input.plan.name.toLowerCase(),
amount: hasCharge ? proratedAmount : 0,
currency: input.finalCurrency,
interval: input.plan.interval,
quantity: newSeatCount,
groupId: normalizeSubscriptionGroup(input.plan.group),
callbackURL: input.callbackURL
}
}, async (providerReference, setPhase) => {
let completedProrationReference;
if (costDifference > 0 && remainingDays > 0) {
const paystack = createPaystackAdapter(options.paystackClient);
if (credentials?.authorizationCode !== void 0 && credentials.authorizationCode !== null && credentials.authorizationCode !== "") {
const sdkRes = await paystack.chargeAuthorization({
email: input.targetEmail,
amount: proratedAmount,
authorization_code: credentials.authorizationCode,
reference: providerReference ?? `upg_${existingSub.id}_${Date.now()}_${Math.random().toString(36).substring(7)}`,
metadata: serializedProrationMetadata
});
if (providerReference !== void 0 && sdkRes?.reference !== providerReference) throw new APIError("BAD_REQUEST", { message: "Provider reference does not match reserved operation." });
if (sdkRes?.status !== "success") throw new APIError("BAD_REQUEST", { message: "Failed to process prorated charge via saved authorization." });
setPhase("persistence");
await store.createTransaction({
reference: sdkRes.reference ?? "",
paystackId: sdkRes.id !== void 0 && sdkRes.id !== null ? String(sdkRes.id) : void 0,
referenceId: input.referenceId,
userId: input.userId,
amount: sdkRes.amount ?? proratedAmount,
currency: sdkRes.currency ?? input.finalCurrency,
status: "success",
plan: input.plan.name.toLowerCase(),
metadata: serializedProrationMetadata,
createdAt: /* @__PURE__ */ new Date(),
updatedAt: /* @__PURE__ */ new Date()
});
completedProrationReference = sdkRes.reference ?? void 0;
} else {
const initRes = await paystack.initializeTransaction({
...providerReference !== void 0 ? { reference: providerReference } : {},
email: input.targetEmail,
amount: proratedAmount,
currency: input.finalCurrency,
callback_url: input.callbackURL ?? void 0,
metadata: serializedProrationMetadata,
...input.allowedSubscriptionChannels !== void 0 ? { channels: input.allowedSubscriptionChannels } : {}
});
if (providerReference !== void 0 && initRes?.reference !== providerReference) throw new APIError("BAD_REQUEST", { message: "Provider reference does not match reserved operation." });
setPhase("persistence");
await store.createTransaction({
reference: initRes?.reference ?? "",
referenceId: input.referenceId,
userId: input.userId,
amount: proratedAmount,
currency: input.finalCurrency,
status: "pending",
plan: input.plan.name.toLowerCase(),
metadata: serializedProrationMetadata,
createdAt: /* @__PURE__ */ new Date(),
updatedAt: /* @__PURE__ */ new Date()
});
return {
kind: "checkout",
url: initRes?.authorization_url,
reference: initRes?.reference,
accessCode: initRes?.access_code,
redirect: true
};
}
}
setPhase("persistence");
await store.updateSubscription(existingSub.id, {
plan: input.plan.name,
seats: newSeatCount,
...completedProrationReference !== void 0 ? { transactionReference: completedProrationReference } : {},
updatedAt: /* @__PURE__ */ new Date()
});
return {
kind: "prorated",
status: "success",
message: "Subscription successfully upgraded with prorated charge.",
prorated: true
};
});
}
//#endregion
//#region src/routes.ts
const paystackWebhook = (options, path = "/webhook") => {
return createAuthEndpoint(path, {
method: "POST",
metadata: {
...HIDE_METADATA,
openapi: { operationId: "handlePaystackWebhook" }
},
cloneRequest: true,
disableBody: true
}, async (ctx) => {
const request = getWebhookRequest(ctx);
if (request === void 0 || request === null) throw new APIError("BAD_REQUEST", { message: "Request object is missing from context" });
const payload = await request.text();
const headers = getWebhookHeaders(ctx);
const signature = headers?.get("x-paystack-signature");
if (options.webhook?.verifyIP === true) {
const trustedIPs = options.webhook.trustedIPs ?? [
"52.31.139.75",
"52.49.173.169",
"52.214.14.220"
];
const clientIP = getWebhookClientIP(ctx, headers);
if (clientIP !== void 0 && clientIP !== null && trustedIPs.includes(clientIP) === false) throw new APIError("UNAUTHORIZED", {
message: `Forbidden IP: ${clientIP}`,
status: 401
});
}
if (signature === void 0 || signature === null || signature === "") throw new APIError("UNAUTHORIZED", {
message: "Missing x-paystack-signature header",
status: 401
});
if (await hmacSha512Hex(options.secretKey, payload) !== signature) throw new APIError("UNAUTHORIZED", {
message: "Invalid Paystack webhook signature",
status: 401
});
const event = JSON.parse(payload);
const eventName = event.event;
const data = event.data;
const reference = data?.reference;
const eventId = createHash("sha256").update(payload).digest("hex");
const store = createBillingStore(ctx);
let claimStatus;
try {
let existingEvent = await store.findWebhookEvent(eventId);
if (existingEvent?.status === "processed") return ctx.json({ received: true });
if (existingEvent === null) try {
existingEvent = await store.createWebhookEvent({
eventId,
eventType: eventName,
reference: reference ?? null,
payload,
status: "pending",
createdAt: /* @__PURE__ */ new Date(),
updatedAt: /* @__PURE__ */ new Date()
});
} catch (error) {
existingEvent = await store.findWebhookEvent(eventId);
if (!existingEvent) throw error;
}
if (existingEvent.status === "processed") return ctx.json({ received: true });
if (existingEvent.status.startsWith("processing:") && new Date(existingEvent.updatedAt).getTime() > Date.now() - 3e5) throw new Error("Webhook is already processing; retry later.");
claimStatus = `processing:${randomUUID()}`;
if (await ctx.context.adapter.update({
model: PAYSTACK_MODELS.webhookEvent,
where: [
{
field: "eventId",
value: eventId
},
{
field: "status",
value: existingEvent.status
},
{
field: "updatedAt",
value: existingEvent.updatedAt
}
],
update: {
status: claimStatus,
updatedAt: /* @__PURE__ */ new Date()
}
}) === null) throw new Error("Webhook claim was lost; retry later.");
if (eventName === "charge.success") {
const reference = data?.reference;
const paystackIdRaw = data?.id;
const paystackId = paystackIdRaw !== void 0 && paystackIdRaw !== null ? String(paystackIdRaw) : void 0;
if (reference !== void 0 && reference !== null && reference !== "") {
try {
await ctx.context.adapter.update({
model: "paystackTransaction",
update: {
status: "success",
paystackId,
updatedAt: /* @__PURE__ */ new Date()
},
where: [{
field: "reference",
value: reference
}]
});
} catch (e) {
ctx.context.logger.warn("Failed to update transaction status for charge.success", e);
throw e;
}
if (options.subscription?.enabled === true) {
const pendingTrial = await ctx.context.adapter.findOne({
model: PAYSTACK_MODELS.subscription,
where: [{
field: "transactionReference",
value: reference
}]
});
if (pendingTrial !== null && ["incomplete", "trialing"].includes(pendingTrial.status) && pendingTrial.trialStart !== void 0 && pendingTrial.trialStart !== null) {
const trialClaim = await claimSubscriptionFulfillment(ctx, reference, pendingTrial, "trial-start");
if (trialClaim !== null) try {
const startedTrial = pendingTrial.status === "trialing" ? pendingTrial : await ctx.context.adapter.update({
model: PAYSTACK_MODELS.subscription,
where: [
{
field: "id",
value: pendingTrial.id
},
{
field: "status",
value: "incomplete"
},
{
field: "transactionReference",
value: reference
}
],
update: {
status: "trialing",
updatedAt: /* @__PURE__ */ new Date()
}
});
if (startedTrial === null) throw new Error("Trial activation changed concurrently; retry later.");
await deliverTrialStart(ctx, trialClaim, startedTrial, await getPlanByName(options, pendingTrial.plan) ?? void 0);
} catch (error) {
await releaseSubscriptionFulfillment(ctx, trialClaim);
throw error;
}
}
}
try {
const transaction = await ctx.context.adapter.findOne({
model: "paystackTransaction",
where: [{
field: "reference",
value: reference
}]
});
if (transaction !== void 0 && transaction !== null && transaction.product !== void 0 && transaction.product !== null && transaction.product !== "") {
if (options.paystackClient !== void 0 && options.paystackClient !== null) await syncProductQuantityFromPaystack(ctx, transaction.product, options.paystackClient);
}
} catch (e) {
ctx.context.logger.warn("Failed to sync product quantity", e);
throw e;
}
}
}
if (eventName === "charge.failure") {
const reference = data?.reference;
if (reference !== void 0 && reference !== null && reference !== "") {
try {
await ctx.context.adapter.update({
model: "paystackTransaction",
update: {
status: "failed",
updatedAt: /* @__PURE__ */ new Date()
},
where: [{
field: "reference",
value: reference
}]
});
} catch (e) {
ctx.context.logger.warn("Failed to update transaction status for charge.failure", e);
throw e;
}
if (options.subscription?.enabled === true) {
const pendingTrial = await ctx.context.adapter.findOne({
model: PAYSTACK_MODELS.subscription,
where: [{
field: "transactionReference",
value: reference
}]
});
if (pendingTrial?.status === "incomplete" && pendingTrial.trialStart !== void 0 && pendingTrial.trialStart !== null) {
const endedAt = /* @__PURE__ */ new Date();
await ctx.context.adapter.update({
model: PAYSTACK_MODELS.subscription,
where: [{
field: "id",
value: pendingTrial.id
}],
update: {
status: "canceled",
endedAt,
updatedAt: endedAt
}
});
}
}
}
}
if (eventName === "invoice.payment_failed") {
const invoice = data;
const subscriptionCode = invoice.subscription?.subscription_code ?? invoice.subscription_code;
if (subscriptionCode !== null && subscriptionCode !== void 0 && subscriptionCode !== "") {
const existing = await ctx.context.adapter.findOne({
model: PAYSTACK_MODELS.subscription,
where: [{
field: "subscriptionCode",
value: subscriptionCode
}]
});
if (existing && existing.status !== "canceled" && existing.status !== "past_due") {
const pastDue = {
...existing,
status: "past_due",
updatedAt: /* @__PURE__ */ new Date()
};
await ctx.context.adapter.update({
model: PAYSTACK_MODELS.subscription,
where: [{
field: "id",
value: existing.id
}],
update: {
status: pastDue.status,
updatedAt: pastDue.updatedAt
}
});
const plan = await getPlanByName(options, existing.plan);
if (plan) await options.subscription?.onSubscriptionUpdate?.({
event,
subscription: pastDue,
plan
}, ctx);
}
}
}
if (options.subscription?.enabled === true) try {
if (eventName === "subscription.create") {
const subscriptionData = data;
const subscriptionCode = subscriptionData.subscription_code ?? "";
const customerCode = subscriptionData.customer?.customer_code;
const planCode = subscriptionData.plan?.plan_code;
const metadataObj = parsePaystackMetadata(subscriptionData.metadata);
const referenceIdFromMetadata = typeof metadataObj.referenceId === "string" ? metadataObj.referenceId : void 0;
let planNameFromMetadata = typeof metadataObj.plan === "string" ? metadataObj.plan : void 0;
if (typeof planNameFromMetadata === "string") planNameFromMetadata = planNameFromMetadata.toLowerCase();
const plans = await getPlans(options.subscription);
const planFromCode = planCode !== void 0 && planCode !== null && planCode !== "" ? plans.find((p) => p.planCode === planCode) : void 0;
const groupIdFromMetadata = typeof metadataObj.groupId === "string" ? normalizeSubscriptionGroup(metadataObj.groupId) : normalizeSubscriptionGroup(planFromCode?.group);
const planPart = planFromCode?.name ?? planNameFromMetadata;
const planName = planPart !== void 0 && planPart !== null && planPart !== "" ? planPart.toLowerCase() : void 0;
if (subscriptionCode !== void 0 && subscriptionCode !== null && subscriptionCode !== "") {
const where = [];
if (referenceIdFromMetadata !== void 0 && referenceIdFromMetadata !== null && referenceIdFromMetadata !== "") where.push({
field: "referenceId",
value: referenceIdFromMetadata
});
else if (customerCode !== void 0 && customerCode !== null && customerCode !== "") where.push({
field: "customerCode",
value: customerCode
});
if (planName !== void 0 && planName !== null && planName !== "") where.push({
field: "plan",
value: planName
});
if (where.length > 0) {
const subscription = (await ctx.context.adapter.findMany({
model: PAYSTACK_MODELS.subscription,
where
}))?.find((candidate) => candidate.status !== "canceled" && (candidate.subscriptionCode === subscriptionCode || candidate.status === "incomplete" || candidate.status === "trialing" && (candidate.subscriptionCode === void 0 || candidate.subscriptionCode === null || candidate.subscriptionCode === "")) && groupIdFromMetadata === null ? candidate.groupId === void 0 || candidate.groupId === null || candidate.groupId === "" : candidate.groupId === groupIdFromMetadata);
if (subscription !== void 0 && subscription !== null) {
const plan = planFromCode ?? (planName !== void 0 && planName !== null && planName !== "" ? await getPlanByName(options, planName) : void 0);
const now = /* @__PURE__ */ new Date();
const persistedSubscription = {
...subscription,
subscriptionCode,
status: "active",
billingInterval: plan?.interval ?? subscription.billingInterval ?? null,
periodEnd: subscriptionData.next_payment_date !== void 0 && subscriptionData.next_payment_date !== null ? new Date(subscriptionData.next_payment_date) : subscription.periodEnd,
updatedAt: now
};
await ctx.context.adapter.update({
model: PAYSTACK_MODELS.subscription,
update: {
subscriptionCode: persistedSubscription.subscriptionCode,
status: persistedSubscription.status,
billingInterval: persistedSubscription.billingInterval,
periodEnd: persistedSubscription.periodEnd,
updatedAt: persistedSubscription.updatedAt
},
where: [{
field: "id",
value: subscription.id
}]
});
await createBillingStore(ctx).retireCompetingSubscriptions(subscription.referenceId, subscription.groupId ?? null, subscription.id);
if (plan !== void 0 && plan !== null) {
const callbackData = {
event,
subscription: persistedSubscription,
plan
};
for (const callback of [
options.subscription.onSubscriptionComplete,
options.subscription.onSubscriptionCreated,
options.subscription.onSubscriptionUpdate
]) try {
await callback?.(callbackData, ctx);
} catch (error) {
ctx.context.logger.error("Paystack subscription callback failed", error);
throw error;
}
if (subscription.status === "trialing") try {
await plan.freeTrial?.onTrialEnd?.(persistedSubscription);
} catch (error) {
ctx.context.logger.error("Paystack trial end callback failed", error);
throw error;
}
}
}
}
}
}
if (eventName === "subscription.disable" || eventName === "subscription.not_renew") {
const subscriptionData = data;
const subscriptionCode = subscriptionData.subscription_code ?? "";
if (subscriptionCode !== "") {
const existing = await ctx.context.adapter.findOne({
model: PAYSTACK_MODELS.subscription,
where: [{
field: "subscriptionCode",
value: subscriptionCode
}]
});
let newStatus = "canceled";
const nextPaymentDate = subscriptionData.next_payment_date;
const periodEnd = nextPaymentDate !== void 0 && nextPaymentDate !== null && nextPaymentDate !== "" ? new Date(nextPaymentDate) : existing?.periodEnd !== void 0 && existing.periodEnd !== null ? new Date(existing.periodEnd) : void 0;
if (periodEnd !== void 0 && periodEnd.getTime() > Date.now()) newStatus = "active";
const now = /* @__PURE__ */ new Date();
const persistedSubscription = existing === null || existing === void 0 ? void 0 : {
...existing,
status: newStatus,
cancelAtPeriodEnd: newStatus === "active",
cancelAt: newStatus === "active" ? periodEnd ?? null : null,
canceledAt: now,
endedAt: newStatus === "canceled" ? now : null,
...periodEnd ? { periodEnd } : {},
updatedAt: now
};
await ctx.context.adapter.update({
model: PAYSTACK_MODELS.subscription,
update: {
status: newStatus,
cancelAtPeriodEnd: newStatus === "active",
cancelAt: newStatus === "active" ? periodEnd ?? null : null,
canceledAt: now,
endedAt: newStatus === "canceled" ? now : null,
...periodEnd ? { periodEnd } : {},
updatedAt: now
},
where: [{
field: "subscriptionCode",
value: subscriptionCode
}]
});
if (persistedSubscription !== void 0) {
try {
await options.subscription.onSubscriptionCancel?.({
event,
subscription: persistedSubscription
}, ctx);
} catch (error) {
ctx.context.logger.error("Paystack subscription cancel callback failed", error);
}
const plan = await getPlanByName(options, persistedSubscription.plan);
if (plan !== void 0 && plan !== null) {
try {
await options.subscription.onSubscriptionUpdate?.({
event,
subscription: persistedSubscription,
plan
}, ctx);
} catch (error) {
ctx.context.logger.error("Paystack subscription update callback failed", error);
}
if (existing?.status === "trialing" && newStatus === "canceled") try {
await plan.freeTrial?.onTrialExpired?.(persistedSubscription);
} catch (error) {
ctx.context.logger.error("Paystack trial expiry callback failed", error);
}
}
}
}
}
if (eventName === "charge.success" || eventName === "invoice.update") {
const subscriptionCodeRaw = (data?.subscription)?.subscription_code ?? data?.subscription_code;
const subscriptionCode = subscriptionCodeRaw !== void 0 && subscriptionCodeRaw !== null && subscriptionCodeRaw !== "" ? subscriptionCodeRaw : void 0;
if (subscriptionCode !== void 0) {
const existingSub = await ctx.context.adapter.findOne({
model: PAYSTACK_MODELS.subscription,
where: [{
field: "subscriptionCode",
value: subscriptionCode
}]
});
if (existingSub?.status === "past_due") {
const recovered = {
...existingSub,
status: "active",
updatedAt: /* @__PURE__ */ new Date()
};
await ctx.context.adapter.update({
model: PAYSTACK_MODELS.subscription,
update: {
status: recovered.status,
updatedAt: recovered.updatedAt
},
where: [{
field: "id",
value: existingSub.id
}]
});
const plan = await getPlanByName(options, existingSub.plan);
if (plan) await options.subscription.onSubscriptionUpdate?.({
event,
subscription: recovered,
plan
}, ctx);
}
if (existingSub !== void 0 && existingSub !== null && existingSub.pendingPlan !== void 0 && existingSub.pendingPlan !== null && existingSub.pendingPlan !== "") await ctx.context.adapter.update({
model: PAYSTACK_MODELS.subscription,
update: {
plan: existingSub.pendingPlan,
pendingPlan: null,
updatedAt: /* @__PURE__ */ new Date()
},
where: [{
field: "id",
value: existingSub.id
}]
});
}
}
} catch (_e) {
ctx.context.logger.error("Failed to sync Paystack webhook event", _e);
throw _e;
}
await options.onEvent?.(event);
if (await ctx.context.adapter.update({
model: PAYSTACK_MODELS.webhookEvent,
where: [{
field: "eventId",
value: eventId
}, {
field: "status",
value: claimStatus
}],
update: {
status: "processed",
processedAt: /* @__PURE__ */ new Date(),
updatedAt: /* @__PURE__ */ new Date()
}
}) === null) throw new Error("Webhook processing lease was lost.");
return ctx.json({ received: true });
} catch (error) {
if (claimStatus !== void 0) await ctx.context.adapter.update({
model: PAYSTACK_MODELS.webhookEvent,
where: [{
field: "eventId",
value: eventId
}, {
field: "status",
value: claimStatus
}],
update: {
status: "pending",
updatedAt: /* @__PURE__ */ new Date()
}
});
ctx.context.logger.error("Paystack webhook processing failed; delivery must be retried", error);
throw new APIError("SERVICE_UNAVAILABLE", { message: "Webhook processing failed; retry delivery." });
}
});
};
const initializeTransaction = (options, path = "/initialize-transaction") => {
const subscriptionOptions = options.subscription;
const useMiddlewares = subscriptionOptions?.enabled === true ? [
sessionMiddleware,
originCheckMiddleware,
referenceMiddleware(options, "initialize-transaction")
] : [sessionMiddleware, originCheckMiddleware];
return createAuthEndpoint(path, {
method: "POST",
body: initializeTransactionBodySchema,
use: useMiddlewares
}, async (ctx) => {
const paystack = getPaystackOps(options.paystackClient);
const { plan: planName, product: productName, amount: bodyAmount, currency, email, metadata: extraMetadata, callbackURL, quantity, scheduleAtPeriodEnd, cancelAtPeriodEnd, prorateAndCharge, subscriptionId, confirmTrialTokenizationCharge } = ctx.body;
if (callbackURL !== void 0 && callbackURL !== null && callbackURL !== "") {
if (!isTrustedCallbackURL(callbackURL, ctx.context?.baseURL ?? ctx.request?.url ?? "")) throw new APIError("FORBIDDEN", {
message: "callbackURL is not a trusted origin.",
status: 403
});
}
const session = await getSessionFromCtx(ctx);
if (session === void 0 || session === null) throw new APIError("UNAUTHORIZED");
const user = session.user;
if (subscriptionOptions?.enabled === true && subscriptionOptions.requireEmailVerification === true && user.emailVerified !== true) throw new APIError("BAD_REQUEST", {
code: "EMAIL_VERIFICATION_REQUIRED",
message: PAYSTACK_ERROR_CODES.EMAIL_VERIFICATION_REQUIRED.message
});
let plan;
let product;
if (planName !== void 0 && planName !== null && planName !== "") {
if (subscriptionOptions?.enabled !== true) throw new APIError("BAD_REQUEST", { message: "Subscriptions are not enabled." });
plan = await getPlanByName(options, planName) ?? void 0;
if (plan === void 0 || plan === null) try {
const nativePlan = await ctx.context.adapter.findOne({
model: "paystackPlan",
where: [{
field: "name",
value: planName
}]
});
if (nativePlan !== void 0 && nativePlan !== null) plan = nativePlan;
else plan = await ctx.context.adapter.findOne({
model: "paystackPlan",
where: [{
field: "planCode",
value: planName
}]
}) ?? void 0;
} catch {
plan = void 0;
}
if (plan === void 0 || plan === null) throw new APIError("BAD_REQUEST", {
code: "SUBSCRIPTION_PLAN_NOT_FOUND",
message: PAYSTACK_ERROR_CODES.SUBSCRIPTION_PLAN_NOT_FOUND.message,
status: 400
});
} else if (productName !== void 0 && productName !== null && productName !== "") {
if (typeof productName === "string") {
product = await getProductByName(options, productName) ?? void 0;
product ??= await ctx.context.adapter.findOne({
model: "paystackProduct",
where: [{
field: "name",
value: productName
}]
}) ?? void 0;
}
if (product === void 0 || product === null) throw new APIError("BAD_REQUEST", {
message: `Product '${productName}' not found.`,
status: 400
});
} else if (bodyAmount === void 0 || bodyAmount === null) throw new APIError("BAD_REQUEST", {
message: "Either 'plan', 'product', or 'amount' is required to initialize a transaction.",
status: 400
});
let amount = plan !== void 0 ? plan.amount : product ? product.price ?? product.amount : bodyAmount;
const finalCurrency = plan !== void 0 || product ? product?.currency ?? product?.currency ?? plan?.currency ?? "NGN" : currency ?? "NGN";
const referenceIdFromCtx = ctx.context.referenceId;
const referenceId = ctx.body.referenceId ?? referenceIdFromCtx ?? session.user.id;
const groupId = normalizeSubscriptionGroup(plan?.group);
if (subscriptionId !== void 0) {
const selectedSubscription = await createBillingStore(ctx).findSubscriptionById(subscriptionId);
if (selectedSubscription === null || selectedSubscription.referenceId !== referenceId || normalizeSubscriptionGroup(selectedSubscription.groupId) !== groupId) throw new APIError("BAD_REQUEST", { message: "Subscription does not belong to the authorized reference and plan group." });
}
const scheduledChange = await scheduleSubscriptionLifecycleChange(ctx, {
userId: user.id,
referenceId,
subscriptionId,
plan,
scheduleAtPeriodEnd,
cancelAtPeriodEnd
}, options);
if (scheduledChange !== null) return ctx.json(scheduledChange);
let validatedQuantity = quantity ?? 1;
if (product && amount !== void 0) amount *= quantity ?? 1;
if (amount !== void 0 && (!Number.isSafeInteger(amount) || amount <= 0)) throw new APIError("BAD_REQUEST", { message: "Catalog amount must be a positive safe integer." });
if (plan !== void 0) try {
if (getPlanSeatAmount(plan) !== void 0) {
const members = await ctx.context.adapter.findMany({
model: "member",
where: [{
field: "organizationId",
value: referenceId
}]
});
const seatCount = members.length > 0 ? members.length : 1;
const quantityToUse = quantity ?? seatCount;
validatedQuantity = quantityToUse;
amount = calculatePlanAmount(plan, quantityToUse);
}
} catch (error) {
throw new APIError("BAD_REQUEST", { message: error instanceof Error ? error.message : "Invalid seat configuration for plan." });
}
let url;
let reference;
let accessCode;
const trial = await resolveTrialLifecycle(ctx, {
referenceId,
plan
});
const { trialStart, trialEnd } = trial;
const trialTokenizationCharge = trial.granted ? getTrialTokenizationCharge(plan, finalCurrency) : void 0;
if (trial.granted && confirmTrialTokenizationCharge !== true) throw new APIError("BAD_REQUEST", {
code: "TRIAL_TOKENIZATION_CHARGE_CONFIRMATION_REQUIRED",
message: `This Paystack trial requires a one-time tokenization charge of ${trialTokenizationCharge} minor units in ${finalCurrency}. Tell the customer the exact charge and retry with confirmTrialTokenizationCharge: true after they accept it.`
});
const normalizeInitializationError = (error) => {
ctx.context.logger.error("Failed to initialize Paystack transaction", error);
return new APIError("BAD_REQUEST", {
code: "FAILED_TO_INITIALIZE_TRANSACTION",
message: error instanceof Error ? error.message : PAYSTACK_ERROR_CODES.FAILED_TO_INITIALIZE_TRANSACTION.message
});
};
const prepared = await (async () => {
const targetEmail = await resolveCheckoutTargetEmail(ctx, options, {
email,
referenceId,
user
});
const allowedSubscriptionChannels = plan ? getAllowedSubscriptionChannels(options) : void 0;
const metadata = stringifyPaystackMetadata(createCheckoutMetadata({
referenceId,
userId: user.id,
plan: plan !== void 0 ? plan.name.toLowerCase() : void 0,
groupId,
product: product !== void 0 ? product.name.toLowerCase() : void 0,
extra: extraMetadata,
trial: {
isTrial: trialStart !== void 0,
requested: trial.requested,
granted: trial.granted,
deniedReason: trial.deniedReason,
endsAt: trialEnd
}
}));
const initBody = {
email: targetEmail,
callback_url: callbackURL ?? void 0,
metadata,
currency: finalCurrency,
quantity
};
if (allowedSubscriptionChannels !== void 0) initBody.channels = allowedSubscriptionChannels;
if (plan !== void 0 && prorateAndCharge === true) {
const proration = await handleProratedUpgrade(ctx, options, {
plan,
referenceId,
subscriptionId,
quantity,
targetEmail,
userId: user.id,
finalCurrency,
callbackURL,
allowedSubscriptionChannels
});
if (proration?.kind === "checkout") return { earlyResult: {
kind: "checkout",
url: proration.url ?? "",
reference: proration.reference ?? "",
accessCode: proration.accessCode ?? "",
redirect: proration.redirect
} };
if (proration?.kind === "prorated") return { earlyResult: {
kind: "prorated",
status: proration.status,
message: proration.message,
prorated: proration.prorated
} };
}
if (plan !== void 0) {
if (trialStart !== void 0) initBody.amount = trialTokenizationCharge;
else {
initBody.plan = plan.planCode;
initBody.invoice_limit = plan.invoiceLimit;
let finalAmount;
if (amount !== void 0 && amount !== null) {
finalAmount = amount;
initBody.quantity = 1;
} else finalAmount = (plan.amount ?? 0) * (quantity ?? 1);
initBody.amount = Math.max(Math.round(finalAmount), 5e3);
}
} else {
if (amount === void 0 || amount === null) throw new APIError("BAD_REQUEST", { message: "Amount is required for one-time payments" });
initBody.amount = Math.round(amount);
}
return { initBody };
})().catch((error) => {
if (options.subscription?.operationHooks === void 0) throw normalizeInitializationError(error);
throw error;
});
if (prepared.earlyResult !== void 0) return ctx.json(prepared.earlyResult);
const initBody = prepared.initBody;
const operation = plan !== void 0 && options.subscription?.operationHooks !== void 0 ? {
actor: { id: user.id },
referenceId,
kind: "initialize",
subscription: subscriptionId !== void 0 ? await createBillingStore(ctx).findSubscriptionById(subscriptionId) : await createBillingStore(ctx).findCurrentSubscription(referenceId, groupId),
intent: {
plan: plan.name.toLowerCase(),
amount: initBody.amount,
currency: finalCurrency,
interval: plan.interval,
quantity: validatedQuantity,
groupId,
callbackURL,
trialStart,
trialEnd
}
} : void 0;
const execute = async (providerReference, setPhase) => {
if (providerReference !== void 0) initBody.reference = providerReference;
try {
const sdkRes = unwrapSdkResult(await paystack?.transaction?.initialize({ body: initBody }));
if (providerReference !== void 0 && sdkRes?.reference !== providerReference) throw new APIError("BAD_REQUEST", { message: "Provider reference does not match reserved operation." });
url = sdkRes?.authorization_url;
reference = sdkRes?.reference;
accessCode = sdkRes?.access_code;
} catch (error) {
throw normalizeInitializationError(error);
}
setPhase("persistence");
await ctx.context.adapter.create({
model: PAYSTACK_MODELS.transaction,
data: {
reference: reference ?? "",
referenceId,
userId: user.id,
amount: initBody.amount ?? 0,
currency: finalCurrency,
status: "pending",
plan: plan !== void 0 ? plan.name.toLowerCase() : void 0,
product: product !== void 0 ? product.name.toLowerCase() : void 0,
metadata: hasPaystackMetadata(extraMetadata) ? stringifyPaystackMetadata(extraMetadata) : void 0,
createdAt: /* @__PURE__ */ new Date(),
updatedAt: /* @__PURE__ */ new Date()
}
});
if (plan !== void 0) {
const store = createBillingStore(ctx);
let customerCode = (await store.findCustomerByReference(referenceId === user.id ? "user" : "organization", referenceId))?.customerCode;
if (customerCode === void 0) try {
customerCode = (await ctx.context.adapter.findOne({
model: referenceId === user.id ? "user" : "organization",
where: [{
field: "id",
value: referenceId
}],
select: ["paystackCustomerCode"]
}))?.paystackCustomerCode ?? void 0;
if (customerCode !== void 0 && customerCode !== null && customerCode !== "") await store.saveCustomer(referenceId === user.id ? "user" : "organization", referenceId, customerCode);
} catch {}
await ctx.context.adapter.create({
model: PAYSTACK_MODELS.subscription,
data: {
plan: plan.name.toLowerCase(),
groupId,
referenceId,
userId: user.id,
customerCode,
subscriptionCode: void 0,
planCode: plan.planCode,
transactionReference: reference ?? "",
status: "incomplete",
billingInterval: plan.interval ?? null,
seats: quantity ?? 1,
periodStart: /* @__PURE__ */ new Date(),
periodEnd: new Date(Date.now() + 2592e6),
cancelAtPeriodEnd: false,
trialStart,
trialEnd,
createdAt: /* @__PURE__ */ new Date(),
updatedAt: /* @__PURE__ */ new Date()
}
});
}
return ctx.json({
kind: "checkout",
url: url ?? "",
reference: reference ?? "",
accessCode: accessCode ?? "",
redirect: true,
...trial.granted && trialTokenizationCharge !== void 0 ? { trial: {
days: trial.requestedDays,
tokenizationCharge: {
amount: trialTokenizationCharge,
currency: finalCurrency
}
} } : {}
});
};
return operation !== void 0 ? runSubscriptionOperation(ctx, options, operation, execute) : execute(void 0, () => void 0);
});
};
function getTrialTokenizationCharge(plan, currency) {
const configured = plan?.freeTrial?.tokenizationCharge;
if (configured !== void 0) {
if (!Number.isSafeInteger(configured) || configured <= 0) throw new APIError("BAD_REQUEST", {
code: "INVALID_TRIAL_TOKENIZATION_CHARGE",
message: "freeTrial.tokenizationCharge must be a positive safe integer in minor units."
});
return configured;
}
const amount = {
NGN: 5e3,
GHS: 10,
ZAR: 100,
KES: 300,
USD: 200
}[currency.toUpperCase()];
if (amount === void 0) throw new APIError("BAD_REQUEST", {
code: "TRIAL_TOKENIZATION_CHARGE_REQUIRED",
message: `Configure freeTrial.tokenizationCharge in minor units for ${currency}.`
});
return amount;
}
const createSubscription = (options, path = "/create-subscription") => initializeTransaction(options, path);
const upgradeSubscription = (options, path = "/upgrade-subscription") => initializeTransaction(options, path);
const cancelSubscription = (options, path = "/cancel-subscription") => disablePaystackSubscription(options, path);
const restoreSubscription = (options, path = "/restore-subscription") => enablePaystackSubscription(options, path);
const verifyTransaction = (options, path = "/verify-transaction") => {
const verifyBodySchema = z.object({ reference: z.string() });
const useMiddlewares = options.subscription?.enabled === true ? [
sessionMiddleware,
originCheckMiddleware,
referenceMiddleware(options, "verify-transaction")
] : [sessionMiddleware, originCheckMiddleware];
return createAuthEndpoint(path, {
method: "POST",
body: verifyBodySchema,
use: useMiddlewares
}, async (ctx) => {
const session = await getSessionFromCtx(ctx);
const result = await reconcilePaystackTransaction(ctx, options, {
reference: ctx.body.reference,
source: "browser",
actor: session !== void 0 && session !== null ? {
user: session.user,
session: session.session
} : void 0,
throwOnError: true
});
if (!result.ok || result.data === null) throw new APIError("BAD_REQUEST", {
code: result.error?.code ?? "FAILED_TO_VERIFY_TRANSACTION",
message: result.error?.message ?? PAYSTACK_ERROR_CODES.FAILED_TO_VERIFY_TRANSACTION.message,
status: result.error?.status
});
return ctx.json({
status: result.status,
reference: result.reference,
data: result.data
});
});
};
const listSubscriptions = (options, path = "/list-subscriptions") => {
const listQuerySchema = z.object({ referenceId: z.string().optional() });
const subscriptionOptions = options.subscription;
const useMiddlewares = subscriptionOptions?.enabled === true ? [
sessionMiddleware,
originCheckMiddleware,
referenceMiddleware(options, "list-subscriptions")
] : [sessionMiddleware, originCheckMiddleware];
return createAuthEndpoint(path, {
method: "GET",
query: listQuerySchema,
use: useMiddlewares
}, async (ctx) => {
if (subscriptionOptions?.enabled !== true) throw new APIError("BAD_REQUEST", { message: "Subscriptions are not enabled in the Paystack options." });
const session = await getSessionFromCtx(ctx);
if (session === void 0 || session === null) throw new APIError("UNAUTHORIZED");
const store = createBillingStore(ctx);
const referenceIdPart = ctx.context.referenceId;
const queryRefId = ctx.query?.referenceId ?? (typeof ctx.request?.url === "string" ? new URL(ctx.request.url).searchParams.get("referenceId") ?? void 0 : void 0);
const userId = session.user.id;
if (queryRefId !== void 0 && queryRefId !== userId && referenceIdPart !== queryRefId) await authorizeBillingReference(ctx, options, {
user: session.user,
session: session.session,
referenceId: queryRefId,
action: "list-subscriptions"
});
const referenceId = queryRefId ?? referenceIdPart ?? userId;
const res = await store.findSubscriptionsByReference(referenceId);
return ctx.json({ subscriptions: res });
});
};
const listTransactions = (options, path = "/list-transactions") => {
const listQuerySchema = z.object({ referenceId: z.string().optional() });
const useMiddlewares = options.subscription?.enabled === true ? [
sessionMiddleware,
originCheckMiddleware,
referenceMiddleware(options, "list-transactions")
] : [sessionMiddleware, originCheckMiddleware];
return createAuthEndpoint(path, {
method: "GET",
query: listQuerySchema,
use: useMiddlewares
}, async (ctx) => {
const session = await getSessionFromCtx(ctx);
if (session === void 0 || session === null) throw new APIError("UNAUTHORIZED");
const store = createBillingStore(ctx);
const referenceIdPart = ctx.context.referenceId;
const queryRefId = ctx.query?.referenceId ?? (typeof ctx.request?.url === "string" ? new URL(ctx.request.url).searchParams.get("referenceId") ?? void 0 : void 0);
const userId = session.user.id;
if (queryRefId !== void 0 && queryRefId !== userId && referenceIdPart !== queryRefId) await authorizeBillingReference(ctx, options, {
user: session.user,
session: session.session,
referenceId: queryRefId,
action: "list-transactions"
});
const referenceId = queryRefId ?? referenceIdPart ?? userId;
const transactions = await store.listTransactions(referenceId);
return ctx.json({ transactions });
});
};
const disablePaystackSubscription = (options, path = "/disable-subscription") => {
const useMiddlewares = options.subscription?.enabled === true ? [
sessionMiddleware,
originCheckMiddleware,
referenceMiddleware(options, "disable-subscription")
] : [sessionMiddleware, originCheckMiddleware];
return createAuthEndpoint(path, {
method: "POST",
body: enableDisableBodySchema,
use: useMiddlewares
}, async (ctx) => {
const { subscriptionCode, atPeriodEnd } = ctx.body;
const paystack = getPaystackOps(options.paystackClient);
try {
if (isLocalSubscriptionCode(subscriptionCode)) {
const sub = await ctx.context.adapter.findOne({
model: PAYSTACK_MODELS.subscription,
where: [{
field: "subscriptionCode",
value: subscriptionCode
}]
});
if (sub !== null && sub !== void 0) {
const now = /* @__PURE__ */ new Date();
const immediate = atPeriodEnd === false;
await ctx.context.adapter.update({
model: PAYSTACK_MODELS.subscription,
update: {
status: immediate ? "canceled" : "active",
cancelAtPeriodEnd: !immediate,
cancelAt: immediate ? null : sub.periodEnd ?? null,
canceledAt: now,
endedAt: immediate ? now : null,
updatedAt: now
},
where: [{
field: "id",
value: sub.id
}]
});
return ctx.json({ status: "success" });
}
throw new APIError("BAD_REQUEST", { message: "Subscription not found" });
}
const storedSubscription = await createBillingStore(ctx).findSubscriptionByCode(subscriptionCode);
const storedCredentials = storedSubscription ? await readPaystackPaymentCredentials(ctx.context.adapter, options, storedSubscription.id) : null;
let emailToken = ctx.body.emailToken ?? storedCredentials?.emailToken;
let nextPaymentDate;
try {
const fetchRes = unwrapSdkResult(await paystack?.subscription?.fetch(subscriptionCode));
if (fetchRes !== void 0 && fetchRes !== null) {
emailToken ??= fetchRes.email_token ?? void 0;
nextPaymentDate = fetchRes.next_payment_date ?? void 0;
}
} catch {}
if (emailToken === void 0 || emailToken === null || emailToken === "") try {
const link = unwrapSdkResult(await paystack?.subscription?.manageLink(subscriptionCode))?.link;
if (link !== void 0 && link !== null && link !== "") emailToken = tryGetEmailTokenFromSubscriptionManageLink(link);
} catch {}
if (emailToken === void 0 || emailToken === null || emailToken === "") throw new Error("Could not retrieve email_token for subscription disable.");
if (storedSubscription) await savePaystackPaymentCredentials(ctx.context.adapter, options, storedSubscription.id, { emailToken });
await paystack?.subscription?.disable({ body: {
code: subscriptionCode,
token: emailToken
} });
const periodEnd = nextPaymentDate !== void 0 && nextPaymentDate !== null && nextPaymentDate !== "" ? new Date(nextPaymentDate) : void 0;
const sub = await ctx.context.adapter.findOne({
model: PAYSTACK_MODELS.subscription,
where: [{
field: "subscriptionCode",
value: subscriptionCode
}]
});
if (sub !== void 0 && sub !== null) {
const now = /* @__PURE__ */ new Date();
const immediate = atPeriodEnd === false;
await ctx.context.adapter.update({
model: PAYSTACK_MODELS.subscription,
update: {
status: immediate ? "canceled" : "active",
cancelAtPeriodEnd: !immediate,
cancelAt: immediate ? null : periodEnd ?? sub.periodEnd ?? null,
canceledAt: now,
endedAt: immediate ? now : null,
periodEnd,
updatedAt: now
},
where: [{
field: "id",
value: sub.id
}]
});
} else ctx.context.logger.warn(`Could not find subscription with code ${subscriptionCode} to disable`);
return ctx.json({ status: "success" });
} catch (error) {
ctx.context.logger.error("Failed to disable subscription", error);
const errorMessage = error instanceof Error ? error.message : PAYSTACK_ERROR_CODES.FAILED_TO_DISABLE_SUBSCRIPTION.message;
throw new APIError("BAD_REQUEST", {
code: "FAILED_TO_DISABLE_SUBSCRIPTION",
message: errorMessage
});
}
});
};
const enablePaystackSubscription = (options, path = "/enable-subscription") => {
const useMiddlewares = options.subscription?.enabled === true ? [
sessionMiddleware,
originCheckMiddleware,
referenceMiddleware(options, "enable-subscription")
] : [sessionMiddleware, originCheckMiddleware];
return createAuthEndpoint(path, {
method: "POST",
body: enableDisableBodySchema,
use: useMiddlewares
}, async (ctx) => {
const { subscriptionCode } = ctx.body;
const paystack = getPaystackOps(options.paystackClient);
try {
const storedSubscription = await createBillingStore(ctx).findSubscriptionByCode(subscriptionCode);
const storedCredentials = storedSubscription ? await readPaystackPaymentCredentials(ctx.context.adapter, options, storedSubscription.id) : null;
let emailToken = ctx.body.emailToken ?? storedCredentials?.emailToken;
if (emailToken === void 0 || emailToken === null || emailToken === "") try {
const fetchRes = unwrapSdkResult(await paystack?.subscription?.fetch(subscriptionCode));
if (fetchRes !== void 0 && fetchRes !== null) emailToken = fetchRes.email_token ?? void 0;
} catch {}
if (emailToken === void 0 || emailToken === null || emailToken === "") try {
const link = unwrapSdkResult(await paystack?.subscription?.manageLink(subscriptionCode))?.link;
if (link !== void 0 && link !== null && link !== "") emailToken = tryGetEmailTokenFromSubscriptionManageLink(link);
} catch {}
if (emailToken === void 0 || emailToken === null || emailToken === "") throw new APIError("BAD_REQUEST", { message: "Could not retrieve email_token for subscription enable." });
if (storedSubscription) await savePaystackPaymentCredentials(ctx.context.adapter, options, storedSubscription.id, { emailToken });
await paystack?.subscription?.enable({ body: {
code: subscriptionCode,
token: emailToken
} });
await ctx.context.adapter.update({
model: PAYSTACK_MODELS.subscription,
update: {
status: "active",
cancelAtPeriodEnd: false,
cancelAt: null,
canceledAt: null,
endedAt: null,
updatedAt: /* @__PURE__ */ new Date()
},
where: [{
field: "subscriptionCode",
value: subscriptionCode
}]
});
return ctx.json({ status: "success" });
} catch (error) {
ctx.context.logger.error("Failed to enable subscription", error);
const errorMessage = error instanceof Error ? error.message : PAYSTACK_ERROR_CODES.FAILED_TO_ENABLE_SUBSCRIPTION.message;
throw new APIError("BAD_REQUEST", {
code: "FAILED_TO_ENABLE_SUBSCRIPTION",
message: errorMessage
});
}
});
};
const getSubscriptionManageLink = (options, path = "/subscription-manage-link") => {
const manageLinkQuerySchema = z.object({ subscriptionCode: z.string() });
const useMiddlewares = options.subscription?.enabled === true ? [
sessionMiddleware,
originCheckMiddleware,
referenceMiddleware(options, "get-subscription-manage-link")
] : [sessionMiddleware, originCheckMiddleware];
const handler = async (ctx) => {
const { subscriptionCode } = ctx.query;
if (isLocalSubscriptionCode(subscriptionCode)) return ctx.json({
link: null,
message: "Local subscriptions cannot be managed on Paystack"
});
const paystack = getPaystackOps(options.paystackClient);
try {
const res = unwrapSdkResult(await paystack?.subscription?.manageLink(subscriptionCode));
return ctx.json({ link: res?.link || null });
} catch (error) {
ctx.context.logger.error("Failed to get subscription manage link", error);
const errorMessage = error instanceof Error ? error.message : "Failed to get subscription manage link";
throw new APIError("BAD_REQUEST", { message: errorMessage });
}
};
return createAuthEndpoint(path, {
method: "GET",
query: manageLinkQuerySchema,
use: useMiddlewares
}, handler);
};
const listProducts = (_options, path = "/list-products") => {
return createAuthEndpoint(path, {
method: "GET",
metadata: { openapi: { operationId: "listPaystackProducts" } }
}, async (ctx) => {
const products = await listStoredProducts(ctx);
return ctx.json({ products });
});
};
const listPlans = (_options, path = "/list-plans") => {
return createAuthEndpoint(path, {
method: "GET",
metadata: { ...HIDE_METADATA },
use: [sessionMiddleware]
}, async (ctx) => {
try {
const plans = await listStoredPlans(ctx);
return ctx.json({ plans });
} catch (error) {
ctx.context.logger.error("Failed to list plans", error);
const errorMessage = error instanceof Error ? error.message : "Failed to list plans";
throw new APIError("BAD_REQUEST", { message: errorMessage });
}
});
};
const getConfig = (options, path = "/get-config") => {
return createAuthEndpoint(path, {
method: "GET",
metadata: { openapi: { operationId: "getPaystackConfig" } }
}, async (ctx) => {
return ctx.json(await getConfiguredCatalog(options));
});
};
const transactions = { paystackTransaction: { fields: {
reference: {
type: "string",
required: true,
unique: true
},
paystackId: {
type: "string",
required: false
},
referenceId: {
type: "string",
required: true,
index: true
},
userId: {
type: "string",
required: true,
index: true
},
amount: {
type: "number",
required: true
},
currency: {
type: "string",
required: true
},
status: {
type: "string",
required: true
},
plan: {
type: "string",
required: false
},
product: {
type: "string",
required: false
},
metadata: {
type: "string",
required: false
},
createdAt: {
type: "date",
required: true
},
updatedAt: {
type: "date",
required: true
}
} } };
const subscriptions = { paystackSubscription: { fields: {
plan: {
type: "string",
required: true,
index: true
},
referenceId: {
type: "string",
required: true,
index: true
},
userId: {
type: "string",
required: true,
index: true
},
customerCode: {
type: "string",
required: false,
index: true
},
subscriptionCode: {
type: "string",
required: false,
unique: true
},
transactionReference: {
type: "string",
required: false,
index: true
},
planCode: {
type: "string",
required: false
},
status: {
type: "string",
defaultValue: "incomplete"
},
periodStart: {
type: "date",
required: false
},
periodEnd: {
type: "date",
required: false
},
trialStart: {
type: "date",
required: false
},
trialEnd: {
type: "date",
required: false
},
cancelAtPeriodEnd: {
type: "boolean",
required: false,
defaultValue: false
},
cancelAt: {
type: "date",
required: false
},
canceledAt: {
type: "date",
required: false
},
endedAt: {
type: "date",
required: false
},
billingInterval: {
type: "string",
required: false
},
groupId: {
type: "string",
required: false
},
seats: {
type: "number",
required: false
},
pendingPlan: {
type: "string",
required: false
},
createdAt: {
type: "date",
required: true
},
updatedAt: {
type: "date",
required: true
}
} } };
const customers = { paystackCustomer: { fields: {
referenceType: {
type: "string",
required: true
},
referenceId: {
type: "string",
required: true,
index: true
},
referenceKey: {
type: "string",
required: true,
unique: true
},
customerCode: {
type: "string",
required: true,
unique: true
},
email: {
type: "string",
required: false
},
createdAt: {
type: "date",
required: true
},
updatedAt: {
type: "date",
required: true
}
} } };
const paymentCredentials = { paystackPaymentCredential: { fields: {
subscriptionId: {
type: "string",
required: true,
unique: true,
index: true
},
authorizationCodeEncrypted: {
type: "string",
required: false
},
emailTokenEncrypted: {
type: "string",
required: false
},
createdAt: {
type: "date",
required: true
},
updatedAt: {
type: "date",
required: true
}
} } };
const user = { user: { fields: {} } };
const organization = { organization: { fields: { email: {
type: "string",
required: false
} } } };
const products = { paystackProduct: { fields: {
name: {
type: "string",
required: true
},
description: {
type: "string",
required: false
},
price: {
type: "number",
required: true
},
currency: {
type: "string",
required: true
},
quantity: {
type: "number",
required: false,
defaultValue: 0
},
unlimited: {
type: "boolean",
required: false,
defaultValue: true
},
paystackId: {
type: "string",
required: false,
unique: true
},
slug: {
type: "string",
required: true,
unique: true
},
metadata: {
type: "string",
required: false
},
createdAt: {
type: "date",
required: true
},
updatedAt: {
type: "date",
required: true
}
} } };
const plans = { paystackPlan: { fields: {
name: {
type: "string",
required: true
},
description: {
type: "string",
required: false
},
amount: {
type: "number",
required: true
},
currency: {
type: "string",
required: true
},
interval: {
type: "string",
required: true
},
group: {
type: "string",
required: false
},
planCode: {
type: "string",
required: true,
unique: true
},
paystackId: {
type: "string",
required: true,
unique: true
},
metadata: {
type: "string",
required: false
},
createdAt: {
type: "date",
required: true
},
updatedAt: {
type: "date",
required: true
}
} } };
const webhookEvents = { paystackWebhookEvent: { fields: {
eventId: {
type: "string",
required: true,
unique: true
},
eventType: {
type: "string",
required: true,
index: true
},
reference: {
type: "string",
required: false,
index: true
},
payload: {
type: "string",
required: true
},
status: {
type: "string",
required: true,
defaultValue: "pending",
index: true
},
processedAt: {
type: "date",
required: false
},
createdAt: {
type: "date",
required: true
},
updatedAt: {
type: "date",
required: true
}
} } };
({
...subscriptions,
...transactions,
...customers,
...paymentCredentials,
...user,
...organization,
...products,
...plans,
...webhookEvents
});
const getSchema = (options) => {
let baseSchema;
const optionSchema = options.schema;
if (options.subscription?.enabled === true) baseSchema = {
...subscriptions,
...transactions,
...customers,
...paymentCredentials,
...user,
...products,
...plans,
...webhookEvents
};
else baseSchema = {
...user,
...transactions,
...customers,
...paymentCredentials,
...products,
...plans,
...webhookEvents
};
if (options.organization?.enabled === true) baseSchema = {
...baseSchema,
...organization
};
if (options.schema !== void 0 && "subscription" in options.schema) {
const { subscription: _subscription, ...restSchema } = optionSchema ?? {};
return mergeSchema(baseSchema, restSchema);
}
return mergeSchema(baseSchema, optionSchema);
};
//#endregion
//#region src/operations.ts
async function syncPaystackProducts(ctx, options) {
const paystack = createPaystackAdapter(options.paystackClient);
const store = createBillingStore(ctx);
try {
const productsData = await paystack.listProducts();
if (!Array.isArray(productsData)) return {
status: "success",
count: 0
};
for (const product of productsData) {
const paystackId = String(product.id);
const productFields = {
name: product.name ?? "",
description: product.description ?? "",
price: product.price ?? 0,
currency: product.currency ?? "",
quantity: product.quantity ?? 0,
unlimited: product.unlimited !== void 0 && product.unlimited !== null && product.unlimited !== false,
paystackId,
slug: product.slug ?? product.name?.toLowerCase().replace(/\s+/g, "-") ?? "",
metadata: stringifyPaystackMetadata(product.metadata),
updatedAt: /* @__PURE__ */ new Date()
};
await store.upsertProductByPaystackId(paystackId, {
...productFields,
createdAt: /* @__PURE__ */ new Date()
});
}
return {
status: "success",
count: productsData.length
};
} catch (error) {
ctx.context.logger.error("Failed to sync products", error);
const errorMessage = error instanceof Error ? error.message : "Failed to sync products";
throw new APIError("BAD_REQUEST", { message: errorMessage });
}
}
async function syncPaystackPlans(ctx, options) {
const paystack = createPaystackAdapter(options.paystackClient);
const store = createBillingStore(ctx);
try {
const plansData = await paystack.listPlans();
if (!Array.isArray(plansData)) return {
status: "success",
count: 0
};
for (const plan of plansData) {
const paystackId = String(plan.id);
const planData = {
name: plan.name ?? "",
description: typeof plan.description === "string" ? plan.description : "",
amount: plan.amount ?? 0,
currency: plan.currency ?? "",
interval: plan.interval ?? "",
planCode: plan.plan_code ?? "",
paystackId,
metadata: stringifyPaystackMetadata(plan.metadata),
updatedAt: /* @__PURE__ */ new Date()
};
await store.upsertPlanByPaystackId(paystackId, {
...planData,
createdAt: /* @__PURE__ */ new Date()
});
}
return {
status: "success",
count: plansData.length
};
} catch (error) {
ctx.context.logger.error("Failed to sync plans", error);
const errorMessage = error instanceof Error ? error.message : "Failed to sync plans";
throw new APIError("BAD_REQUEST", { message: errorMessage });
}
}
async function chargeSubscriptionRenewal(ctx, options, input) {
const { subscriptionId } = input;
const store = createBillingStore(ctx);
const subscription = await store.findSubscriptionById(subscriptionId);
if (subscription === void 0 || subscription === null) throw new APIError("NOT_FOUND", { message: "Subscription not found" });
const credentials = await readPaystackPaymentCredentials(ctx.context.adapter, options, subscription.id);
if (credentials?.authorizationCode === void 0 || credentials.authorizationCode === null || credentials.authorizationCode === "") throw new APIError("BAD_REQUEST", { message: "No authorization code found for this subscription" });
const plan = (await getPlans(options.subscription)).find((candidate) => candidate.name.toLowerCase() === subscription.plan.toLowerCase());
if (plan === void 0 || plan === null) throw new APIError("NOT_FOUND", { message: "Plan not found" });
const amount = calculatePlanAmount(plan, subscription.seats ?? 1);
if (amount === void 0 || amount === null) throw new APIError("BAD_REQUEST", { message: "Plan amount is not defined" });
let email;
let billingUserId = subscription.userId;
const referenceId = subscription.referenceId;
if (referenceId !== void 0 && referenceId !== null && referenceId !== "") {
const user = await store.findUser(referenceId);
if (user !== void 0 && user !== null) {
email = user.email;
billingUserId = user.id;
} else if (options.organization?.enabled === true) {
const ownerMember = await store.findOrganizationOwner(referenceId);
if (ownerMember !== void 0 && ownerMember !== null) {
const ownerUser = await store.findUser(ownerMember.userId);
email = ownerUser?.email;
billingUserId = ownerUser?.id ?? ownerMember.userId;
}
}
}
if (email === void 0 || email === null || email === "") throw new APIError("NOT_FOUND", { message: "User email not found" });
const finalCurrency = plan.currency ?? "NGN";
if (!validateMinAmount(amount, finalCurrency)) throw new APIError("BAD_REQUEST", {
message: `Amount ${amount} is less than the minimum required for ${finalCurrency}.`,
status: 400
});
const paystack = createPaystackAdapter(options.paystackClient);
const renewalMetadata = createRenewalMetadata({
subscriptionId,
referenceId
});
const serializedRenewalMetadata = stringifyPaystackMetadata(renewalMetadata);
const typedChargeData = await paystack.chargeAuthorization({
email,
amount,
authorization_code: credentials.authorizationCode,
reference: `rec_${subscription.id}_${Date.now()}`,
metadata: serializedRenewalMetadata
});
if (typedChargeData?.status === "success" && typedChargeData.reference !== void 0) {
const now = /* @__PURE__ */ new Date();
const nextPeriodEnd = getNextPeriodEnd(now, plan.interval ?? "monthly");
await store.createTransaction({
reference: typedChargeData.reference,
paystackId: typedChargeData.id !== void 0 && typedChargeData.id !== null ? String(typedChargeData.id) : void 0,
referenceId,
userId: billingUserId,
amount: typedChargeData.amount,
currency: typedChargeData.currency,
status: "success",
plan: plan.name.toLowerCase(),
metadata: serializedRenewalMetadata,
createdAt: now,
updatedAt: now
});
await store.updateSubscription(subscription.id, {
periodStart: now,
periodEnd: nextPeriodEnd,
updatedAt: now,
transactionReference: typedChargeData.reference
});
return {
status: "success",
data: typedChargeData
};
}
return {
status: "failed",
data: typedChargeData
};
}
//#endregion
//#region src/migrations.ts
function asString(value) {
return typeof value === "string" && value !== "" ? value : void 0;
}
function asIdentifier(value) {
if (typeof value === "number" && Number.isFinite(value)) return String(value);
return asString(value);
}
function asDate(value, fallback) {
if (value instanceof Date) return value;
if (typeof value === "string" || typeof value === "number") {
const date = new Date(value);
if (!Number.isNaN(date.getTime())) return date;
}
return fallback;
}
function asOptionalDate(value) {
if (value === null) return null;
if (value === void 0) return void 0;
if (value instanceof Date) return value;
if (typeof value === "string" || typeof value === "number") {
const date = new Date(value);
if (!Number.isNaN(date.getTime())) return date;
}
}
async function findManySafely(adapter, model) {
try {
return await adapter.findMany({ model }) ?? [];
} catch {
return [];
}
}
function migrationData(legacy) {
const now = /* @__PURE__ */ new Date();
const referenceId = asIdentifier(legacy.referenceId) ?? asIdentifier(legacy.userId) ?? "";
const id = asIdentifier(legacy.id);
return {
...id !== void 0 ? { id } : {},
plan: asString(legacy.plan) ?? "",
referenceId,
userId: asIdentifier(legacy.userId) ?? referenceId,
customerCode: asString(legacy.customerCode) ?? asString(legacy.paystackCustomerCode) ?? void 0,
subscriptionCode: asString(legacy.subscriptionCode) ?? asString(legacy.paystackSubscriptionCode) ?? void 0,
transactionReference: asString(legacy.transactionReference) ?? asString(legacy.paystackTransactionReference) ?? void 0,
planCode: asString(legacy.planCode) ?? asString(legacy.paystackPlanCode) ?? void 0,
status: asString(legacy.status) ?? "incomplete",
periodStart: asDate(legacy.periodStart, now),
periodEnd: asOptionalDate(legacy.periodEnd),
trialStart: asOptionalDate(legacy.trialStart),
trialEnd: asOptionalDate(legacy.trialEnd),
cancelAtPeriodEnd: legacy.cancelAtPeriodEnd === true,
cancelAt: asOptionalDate(legacy.cancelAt),
canceledAt: asOptionalDate(legacy.canceledAt),
endedAt: asOptionalDate(legacy.endedAt),
billingInterval: asString(legacy.billingInterval) ?? void 0,
groupId: asString(legacy.groupId) ?? void 0,
seats: typeof legacy.seats === "number" ? legacy.seats : void 0,
pendingPlan: asString(legacy.pendingPlan) ?? void 0,
createdAt: asDate(legacy.createdAt, now),
updatedAt: asDate(legacy.updatedAt, now)
};
}
async function migrateCustomer(adapter, referenceType, legacy, report) {
const customerCode = asString(legacy.paystackCustomerCode) ?? asString(legacy.customerCode);
const referenceId = asIdentifier(legacy.id);
if (customerCode === void 0 || referenceId === void 0) return;
const referenceKey = `${referenceType}:${referenceId}`;
try {
if (await adapter.findOne({
model: PAYSTACK_MODELS.customer,
where: [{
field: "referenceKey",
value: referenceKey
}]
})) {
report.customers.skipped += 1;
report.skipped += 1;
return;
}
const now = /* @__PURE__ */ new Date();
const email = asString(legacy.email);
await adapter.create({
model: PAYSTACK_MODELS.customer,
data: {
referenceType,
referenceId,
referenceKey,
customerCode,
...email !== void 0 ? { email } : {},
createdAt: asDate(legacy.createdAt, now),
updatedAt: asDate(legacy.updatedAt, now)
}
});
report.customers.migrated += 1;
} catch (error) {
report.customers.failed += 1;
report.failures.push({
kind: "customer",
id: referenceKey,
message: error instanceof Error ? error.message : "Failed to migrate customer"
});
}
}
async function migratePaystackSubscriptionSchema(ctx, options) {
const adapter = ctx.context.adapter;
const report = {
status: "complete",
subscriptions: {
migrated: 0,
skipped: 0,
failed: 0
},
customers: {
migrated: 0,
skipped: 0,
failed: 0
},
credentials: {
migrated: 0,
skipped: 0,
failed: 0
},
skipped: 0,
failures: []
};
for (const referenceType of ["user", "organization"]) {
const records = await findManySafely(adapter, referenceType);
for (const record of records) await migrateCustomer(adapter, referenceType, record, report);
}
const legacySubscriptions = await findManySafely(adapter, LEGACY_PAYSTACK_MODELS.subscription);
for (const legacy of legacySubscriptions) {
const id = asIdentifier(legacy.id);
if (id === void 0) continue;
try {
if (await adapter.findOne({
model: PAYSTACK_MODELS.subscription,
where: [{
field: "id",
value: id
}]
})) {
report.subscriptions.skipped += 1;
report.skipped += 1;
} else {
await adapter.create({
model: PAYSTACK_MODELS.subscription,
data: migrationData(legacy),
forceAllowId: true
});
report.subscriptions.migrated += 1;
}
} catch (error) {
report.subscriptions.failed += 1;
report.failures.push({
kind: "subscription",
id,
message: error instanceof Error ? error.message : "Failed to migrate subscription"
});
}
const authorizationCode = asString(legacy.authorizationCode) ?? asString(legacy.paystackAuthorizationCode);
const emailToken = asString(legacy.emailToken) ?? asString(legacy.paystackEmailToken);
if (authorizationCode !== void 0 || emailToken !== void 0) try {
if (await adapter.findOne({
model: PAYSTACK_MODELS.paymentCredential,
where: [{
field: "subscriptionId",
value: id
}]
})) {
report.credentials.skipped += 1;
report.skipped += 1;
} else {
await savePaystackPaymentCredentials(adapter, options, id, {
authorizationCode,
emailToken
});
report.credentials.migrated += 1;
}
} catch (error) {
report.credentials.failed += 1;
report.failures.push({
kind: "credential",
id,
message: error instanceof Error ? error.message : "Failed to migrate credential"
});
}
}
report.status = report.failures.length > 0 ? "partial" : "complete";
return report;
}
//#endregion
//#region src/index.ts
const INTERNAL_ERROR_CODES = defineErrorCodes(Object.fromEntries(Object.entries(PAYSTACK_ERROR_CODES).map(([key, value]) => [key, typeof value === "string" ? value : value.message])));
const createPaystackPlugin = (options) => {
const routeOptions = {
...options,
webhook: options.webhook
};
return {
id: "paystack",
version: PACKAGE_VERSION,
endpoints: {
initializeTransaction: initializeTransaction(routeOptions, "/paystack/initialize-transaction"),
verifyTransaction: verifyTransaction(routeOptions, "/paystack/verify-transaction"),
listSubscriptions: listSubscriptions(routeOptions, "/paystack/list-subscriptions"),
paystackWebhook: paystackWebhook(routeOptions, "/paystack/webhook"),
listTransactions: listTransactions(routeOptions, "/paystack/list-transactions"),
getConfig: getConfig(routeOptions, "/paystack/config"),
disableSubscription: disablePaystackSubscription(routeOptions, "/paystack/disable-subscription"),
enableSubscription: enablePaystackSubscription(routeOptions, "/paystack/enable-subscription"),
getSubscriptionManageLink: getSubscriptionManageLink(routeOptions, "/paystack/subscription-manage-link"),
subscriptionManageLink: getSubscriptionManageLink(routeOptions, "/paystack/subscription/manage-link"),
createSubscription: createSubscription(routeOptions, "/paystack/create-subscription"),
upgradeSubscription: upgradeSubscription(routeOptions, "/paystack/upgrade-subscription"),
cancelSubscription: cancelSubscription(routeOptions, "/paystack/cancel-subscription"),
restoreSubscription: restoreSubscription(routeOptions, "/paystack/restore-subscription"),
listProducts: listProducts(routeOptions, "/paystack/list-products"),
listPlans: listPlans(routeOptions, "/paystack/list-plans")
},
schema: getSchema(options),
init: ((ctx) => {
const organizationPluginAvailable = ctx.hasPlugin("organization");
if (options.organization?.enabled === true && !organizationPluginAvailable) ctx.logger.error("Paystack organization billing is enabled, but the Better Auth organization plugin was not found. Organization billing hooks will be skipped.");
return { options: { databaseHooks: {
user: {
create: { async after(user, hookCtx) {
if (!hookCtx || options.createCustomerOnSignUp !== true || user.email === null || user.email === void 0 || user.email === "") return;
try {
const result = await resolvePaystackCustomer({
adapter: ctx.adapter,
client: options.paystackClient,
logger: ctx.logger,
reference: {
id: user.id,
type: "user",
email: user.email,
name: user.name,
emailVerified: user.emailVerified,
paystackCustomerCode: user.paystackCustomerCode
}
});
const customerCode = result?.customer.customer_code;
if (result?.created === true && typeof customerCode === "string" && typeof options.onCustomerCreate === "function") await options.onCustomerCreate({
paystackCustomer: result.customer,
user: {
...user,
paystackCustomerCode: customerCode
}
}, hookCtx);
} catch (error) {
ctx.logger.error("Failed to create Paystack customer for user", error);
}
} },
update: { async after(user) {
const customerCode = (await createBillingStoreFromAdapter(ctx.adapter).findCustomerByReference("user", user.id))?.customerCode;
if (typeof customerCode !== "string" || customerCode === "" || typeof user.email !== "string" || user.email === "") return;
try {
await createPaystackAdapter(options.paystackClient).updateCustomer(customerCode, { email: user.email });
} catch (error) {
ctx.logger.error("Failed to synchronize Paystack customer email", error);
}
} }
},
organization: options.organization?.enabled === true && organizationPluginAvailable ? {
create: { async after(org, hookCtx) {
try {
const extraCreateParams = typeof options.organization?.getCustomerCreateParams === "function" ? await options.organization.getCustomerCreateParams(org, hookCtx) : {};
let targetEmail = org.email;
if (targetEmail === void 0 || targetEmail === null) {
const store = createBillingStoreFromAdapter(ctx.adapter);
const ownerMember = await store.findOrganizationOwner(org.id);
if (ownerMember !== null && ownerMember !== void 0) targetEmail = (await store.findUser(ownerMember.userId))?.email;
}
if (targetEmail === void 0 || targetEmail === null) return;
const result = await resolvePaystackCustomer({
adapter: ctx.adapter,
client: options.paystackClient,
logger: ctx.logger,
reference: {
id: org.id,
type: "organization",
email: targetEmail,
name: org.name,
paystackCustomerCode: org.paystackCustomerCode
},
createParams: defu({}, extraCreateParams)
});
const customerCode = result?.customer.customer_code;
if (result?.created === true && typeof customerCode === "string" && typeof options.organization?.onCustomerCreate === "function") await options.organization.onCustomerCreate({
paystackCustomer: result.customer,
organization: {
...org,
paystackCustomerCode: customerCode
}
}, hookCtx);
} catch (error) {
ctx.logger.error("Failed to create Paystack customer for organization", error);
}
} },
update: { async after(org, hookCtx) {
const persisted = await createBillingStoreFromAdapter(ctx.adapter).findOrganization(org.id);
const customerCode = (await createBillingStoreFromAdapter(ctx.adapter).findCustomerByReference("organization", org.id))?.customerCode;
if (typeof customerCode !== "string" || customerCode === "") return;
try {
const configured = typeof options.organization?.getCustomerCreateParams === "function" && hookCtx ? await options.organization.getCustomerCreateParams({
id: org.id,
name: org.name ?? persisted?.name ?? "",
email: org.email ?? persisted?.email
}, hookCtx) : {};
const configuredEmail = typeof configured.email === "string" && configured.email !== "" ? configured.email : void 0;
await createPaystackAdapter(options.paystackClient).updateCustomer(customerCode, {
...configuredEmail !== void 0 || typeof org.email === "string" && org.email !== "" ? { email: configuredEmail ?? org.email ?? void 0 } : {},
...typeof org.name === "string" && org.name !== "" ? { first_name: org.name } : {}
});
} catch (error) {
ctx.logger.error("Failed to synchronize Paystack organization customer", error);
}
} },
delete: { async before(org) {
const store = createBillingStoreFromAdapter(ctx.adapter);
if ((await store.findSubscriptionsByReference(org.id)).some((subscription) => subscription.status === "active" || subscription.status === "trialing")) throw new APIError("BAD_REQUEST", { message: "Organization cannot be deleted while it has an active subscription" });
const customerCode = (await store.findCustomerByReference("organization", org.id))?.customerCode;
if (typeof customerCode !== "string" || customerCode === "") return;
try {
if ((await createPaystackAdapter(options.paystackClient).fetchCustomer(customerCode)).subscriptions?.some((subscription) => subscription.status === "active" || subscription.status === "trialing") === true) throw new APIError("BAD_REQUEST", { message: "Organization cannot be deleted while it has an active subscription" });
} catch (error) {
if (error instanceof APIError) throw error;
ctx.logger.error("Failed to check Paystack subscriptions before organization deletion", error);
throw new APIError("BAD_REQUEST", { message: "Organization deletion could not verify its Paystack subscriptions" });
}
} }
} : void 0,
member: organizationPluginAvailable ? {
create: {
before: async (member, ctx) => {
if (options.subscription?.enabled === true && member.organizationId && ctx !== null && ctx !== void 0) await checkSeatLimit(ctx, member.organizationId);
},
after: async (member, ctx) => {
if (options.subscription?.enabled === true && typeof member?.organizationId === "string" && ctx) await syncSubscriptionSeats(ctx, member.organizationId, routeOptions);
}
},
delete: { after: async (member, ctx) => {
if (options.subscription?.enabled === true && typeof member?.organizationId === "string" && ctx) await syncSubscriptionSeats(ctx, member.organizationId, routeOptions);
} }
} : void 0,
invitation: organizationPluginAvailable ? {
create: {
before: async (invitation, ctx) => {
if (options.subscription?.enabled === true && invitation.organizationId && ctx !== null && ctx !== void 0) await checkSeatLimit(ctx, invitation.organizationId);
},
after: async (invitation, ctx) => {
if (options.subscription?.enabled === true && typeof invitation?.organizationId === "string" && ctx) await syncSubscriptionSeats(ctx, invitation.organizationId, routeOptions);
}
},
delete: { after: async (invitation, ctx) => {
if (options.subscription?.enabled === true && typeof invitation?.organizationId === "string" && ctx) await syncSubscriptionSeats(ctx, invitation.organizationId, routeOptions);
} }
} : void 0,
team: organizationPluginAvailable ? { create: { before: async (team, ctx) => {
if (options.subscription?.enabled === true && team.organizationId && ctx) {
const maxTeams = (await getOrganizationEntitlements(ctx, team.organizationId, routeOptions)).limits.teams;
if (typeof maxTeams === "number") await checkTeamLimit(ctx, team.organizationId, maxTeams);
}
} } } : void 0
} } };
}),
$ERROR_CODES: INTERNAL_ERROR_CODES,
options
};
};
const paystack = createPaystackPlugin;
//#endregion
export { chargeSubscriptionRenewal, createCheckoutMetadata, createProrationMetadata, createRenewalMetadata, getMetadataBoolean, getMetadataNumber, getMetadataString, getOrganizationEntitlements, hasPaystackMetadata, migratePaystackSubscriptionSchema, parsePaystackMetadata, paystack, reconcilePaystackTransaction, stringifyPaystackMetadata, syncPaystackPlans, syncPaystackProducts };
//# sourceMappingURL=index.mjs.map