stripe embedded and other fixes
This commit is contained in:
@@ -0,0 +1,401 @@
|
||||
const admin = require("firebase-admin");
|
||||
const { FieldValue } = require("firebase-admin/firestore");
|
||||
|
||||
const { refList } = require("../../index");
|
||||
const { STRIPE_WEBHOOK_SECRET } = require("../../config/keys");
|
||||
const {
|
||||
SUBSCRIPTION_LEVEL_ALLOWANCES,
|
||||
SUBSCRIPTION_PRICE_METADATA,
|
||||
COIN_PACK_PRODUCT_MAP,
|
||||
} = require("./constants");
|
||||
|
||||
const refsList = refList;
|
||||
const paymentsCollection = admin.firestore().collection("payments");
|
||||
let cachedStripeWebhookSecret = null;
|
||||
|
||||
const toFiniteNumber = (value) => {
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
const normalized = value.trim().replace(",", ".");
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
const parsed = Number(normalized);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const parseCoinsPerMonth = (metadata) => {
|
||||
if (!metadata || typeof metadata !== "object") {
|
||||
return null;
|
||||
}
|
||||
if (!Object.prototype.hasOwnProperty.call(metadata, "coinsPerMonth")) {
|
||||
return null;
|
||||
}
|
||||
const candidateValue = toFiniteNumber(metadata.coinsPerMonth);
|
||||
if (candidateValue === null || candidateValue <= 0) {
|
||||
return null;
|
||||
}
|
||||
return Math.round(candidateValue);
|
||||
};
|
||||
|
||||
const parseCoinAmount = (metadata) => {
|
||||
if (!metadata || typeof metadata !== "object") {
|
||||
return null;
|
||||
}
|
||||
if (!Object.prototype.hasOwnProperty.call(metadata, "coins")) {
|
||||
return null;
|
||||
}
|
||||
const candidateValue = toFiniteNumber(metadata.coins);
|
||||
if (candidateValue === null || candidateValue <= 0) {
|
||||
return null;
|
||||
}
|
||||
return Math.round(candidateValue);
|
||||
};
|
||||
|
||||
const toDateSafe = (value) => {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
if (value instanceof Date) {
|
||||
return value;
|
||||
}
|
||||
if (typeof value?.toDate === "function") {
|
||||
try {
|
||||
return value.toDate();
|
||||
} catch (_error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
if (value > 1e12) {
|
||||
return new Date(value);
|
||||
}
|
||||
return new Date(value * 1000);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const addMonths = (date, months = 1) => {
|
||||
if (!(date instanceof Date) || !Number.isFinite(months)) {
|
||||
return null;
|
||||
}
|
||||
const result = new Date(date.getTime());
|
||||
const initialDay = result.getDate();
|
||||
result.setMonth(result.getMonth() + months);
|
||||
if (result.getDate() !== initialDay) {
|
||||
result.setDate(0);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const computeNextGrantTimestamp = (base, months = 1) => {
|
||||
const baseDate = toDateSafe(base);
|
||||
if (!baseDate) {
|
||||
return null;
|
||||
}
|
||||
const nextDate = addMonths(baseDate, months);
|
||||
if (!nextDate) {
|
||||
return null;
|
||||
}
|
||||
return admin.firestore.Timestamp.fromDate(nextDate);
|
||||
};
|
||||
|
||||
const getServerTimestamp = () => {
|
||||
if (typeof FieldValue?.serverTimestamp === "function") {
|
||||
return FieldValue.serverTimestamp();
|
||||
}
|
||||
const fallback = admin.firestore?.FieldValue;
|
||||
if (typeof fallback?.serverTimestamp === "function") {
|
||||
return fallback.serverTimestamp();
|
||||
}
|
||||
throw new Error("Firestore FieldValue.serverTimestamp indisponible.");
|
||||
};
|
||||
|
||||
const resolveStripeWebhookSecret = () => {
|
||||
if (cachedStripeWebhookSecret) {
|
||||
return cachedStripeWebhookSecret;
|
||||
}
|
||||
|
||||
const envSecret =
|
||||
typeof process?.env?.STRIPE_WEBHOOK_SECRET === "string"
|
||||
? process.env.STRIPE_WEBHOOK_SECRET.trim()
|
||||
: "";
|
||||
const inlineSecret =
|
||||
typeof STRIPE_WEBHOOK_SECRET === "string"
|
||||
? STRIPE_WEBHOOK_SECRET.trim()
|
||||
: "";
|
||||
|
||||
const secret = envSecret || inlineSecret;
|
||||
if (!secret) {
|
||||
throw new Error("STRIPE_WEBHOOK_SECRET not configured");
|
||||
}
|
||||
|
||||
cachedStripeWebhookSecret = secret;
|
||||
return cachedStripeWebhookSecret;
|
||||
};
|
||||
|
||||
const toFirestoreTimestamp = (unixSeconds) => {
|
||||
if (typeof unixSeconds !== "number" || !Number.isFinite(unixSeconds)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return admin.firestore.Timestamp.fromMillis(unixSeconds * 1000);
|
||||
} catch (error) {
|
||||
console.error("[subscription-toFirestoreTimestamp] Conversion error", unixSeconds, error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const extractFirebaseUid = (metadata) => {
|
||||
if (!metadata || typeof metadata !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const candidates = [
|
||||
metadata.firebaseUID,
|
||||
metadata.firebaseUid,
|
||||
metadata.uid,
|
||||
metadata.userId,
|
||||
];
|
||||
|
||||
for (let index = 0; index < candidates.length; index += 1) {
|
||||
const candidate = candidates[index];
|
||||
if (typeof candidate === "string" && candidate.trim()) {
|
||||
return candidate.trim();
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const formatCoinPack = ({ product, price }) => {
|
||||
if (!product || typeof product !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const resolvedPrice =
|
||||
price ||
|
||||
(typeof product.default_price === "object" && product.default_price) ||
|
||||
null;
|
||||
|
||||
const priceId =
|
||||
(resolvedPrice && resolvedPrice.id) ||
|
||||
(typeof product.default_price === "string" ? product.default_price : null);
|
||||
|
||||
const coinAmount = parseCoinAmount(product?.metadata || {});
|
||||
if (coinAmount === null) {
|
||||
throw new Error(
|
||||
`[formatCoinPack] Missing metadata.coins on product ${product.id}`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
productId: product.id,
|
||||
priceId,
|
||||
name: product.name || "",
|
||||
description: product.description || "",
|
||||
coinAmount,
|
||||
currency:
|
||||
resolvedPrice?.currency ||
|
||||
(typeof resolvedPrice?.currency === "string"
|
||||
? resolvedPrice.currency.toLowerCase()
|
||||
: "eur"),
|
||||
unitAmount: resolvedPrice?.unit_amount ?? null,
|
||||
metadata: product.metadata || {},
|
||||
};
|
||||
};
|
||||
|
||||
const getSubscriptionMetaFromPrice = (priceId) => {
|
||||
if (typeof priceId !== "string") {
|
||||
return null;
|
||||
}
|
||||
return SUBSCRIPTION_PRICE_METADATA[priceId] || null;
|
||||
};
|
||||
|
||||
const buildEventSnapshot = (eventType, entityId) => {
|
||||
const now = admin.firestore.Timestamp.now();
|
||||
return {
|
||||
eventType: eventType || null,
|
||||
entityId: entityId || null,
|
||||
syncedAt: now,
|
||||
};
|
||||
};
|
||||
|
||||
const formatSubscriptionForClient = (subscription) => {
|
||||
if (!subscription || typeof subscription !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: subscription.id,
|
||||
status: subscription.status,
|
||||
customer: subscription.customer,
|
||||
currentPeriodStart: toFirestoreTimestamp(
|
||||
subscription.current_period_start,
|
||||
),
|
||||
currentPeriodEnd: toFirestoreTimestamp(subscription.current_period_end),
|
||||
cancelAtPeriodEnd: subscription.cancel_at_period_end === true,
|
||||
created: toFirestoreTimestamp(subscription.created),
|
||||
items: Array.isArray(subscription.items?.data)
|
||||
? subscription.items.data.map((item) => ({
|
||||
id: item.id,
|
||||
price: item.price,
|
||||
quantity: item.quantity,
|
||||
}))
|
||||
: [],
|
||||
metadata: subscription.metadata || {},
|
||||
};
|
||||
};
|
||||
|
||||
const buildSubscriptionPayload = (subscription) => {
|
||||
if (!subscription || typeof subscription !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const items = Array.isArray(subscription.items?.data)
|
||||
? subscription.items.data.map((item) => ({
|
||||
id: item.id,
|
||||
priceId: item.price?.id || null,
|
||||
productId: item.price?.product || null,
|
||||
quantity: item.quantity || 0,
|
||||
price: item.price || null,
|
||||
}))
|
||||
: [];
|
||||
|
||||
const primaryItem = items[0] || null;
|
||||
const productId = primaryItem?.productId || null;
|
||||
const priceId = primaryItem?.priceId || null;
|
||||
|
||||
const priceMeta = getSubscriptionMetaFromPrice(priceId) || {};
|
||||
const metadataLevel = subscription.metadata?.subscriptionLevel || null;
|
||||
const resolvedLevel = metadataLevel || priceMeta?.level || null;
|
||||
const metadataPeriod = subscription.metadata?.subscriptionBillingPeriod || null;
|
||||
const resolvedPeriod = metadataPeriod || priceMeta?.billingPeriod || null;
|
||||
|
||||
const customerId =
|
||||
typeof subscription.customer === "string" ? subscription.customer : null;
|
||||
|
||||
return {
|
||||
id: subscription.id,
|
||||
customerId,
|
||||
productId,
|
||||
priceId,
|
||||
items,
|
||||
level: resolvedLevel,
|
||||
billingPeriod: resolvedPeriod,
|
||||
status: subscription.status || null,
|
||||
cancelAtPeriodEnd: subscription.cancel_at_period_end === true,
|
||||
currentPeriodStart: toFirestoreTimestamp(
|
||||
subscription.current_period_start,
|
||||
),
|
||||
currentPeriodEnd: toFirestoreTimestamp(subscription.current_period_end),
|
||||
};
|
||||
};
|
||||
|
||||
const resolveUserContext = async ({ metadata, customerId }) => {
|
||||
const firebaseUid = extractFirebaseUid(metadata);
|
||||
|
||||
if (firebaseUid) {
|
||||
const userRef = refsList?.users?.doc(firebaseUid) || null;
|
||||
if (userRef) {
|
||||
try {
|
||||
const snapshot = await userRef.get();
|
||||
if (snapshot.exists) {
|
||||
return {
|
||||
uid: firebaseUid,
|
||||
userRef,
|
||||
userData: snapshot.data() || null,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
"[subscription-resolveUserContext] Unable to read user",
|
||||
firebaseUid,
|
||||
error?.message || error,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (customerId) {
|
||||
try {
|
||||
const snapshot = await refsList.users
|
||||
.where("stripeCustomerId", "==", customerId)
|
||||
.limit(1)
|
||||
.get();
|
||||
if (!snapshot.empty) {
|
||||
const doc = snapshot.docs[0];
|
||||
return {
|
||||
uid: doc.id,
|
||||
userRef: doc.ref,
|
||||
userData: doc.data() || null,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
"[subscription-resolveUserContext] Unable to query by customer",
|
||||
customerId,
|
||||
error?.message || error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
uid: firebaseUid || null,
|
||||
userRef: null,
|
||||
userData: null,
|
||||
};
|
||||
};
|
||||
|
||||
const upsertPaymentDocument = async (docId, data = {}) => {
|
||||
if (!docId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const docRef = paymentsCollection.doc(docId);
|
||||
try {
|
||||
await docRef.set(
|
||||
{
|
||||
...data,
|
||||
updatedAt: getServerTimestamp(),
|
||||
createdAt: getServerTimestamp(),
|
||||
},
|
||||
{ merge: true },
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"[subscription-upsertPaymentDocument] Failed to persist payment",
|
||||
docId,
|
||||
error,
|
||||
);
|
||||
}
|
||||
return docRef;
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
admin,
|
||||
refsList,
|
||||
paymentsCollection,
|
||||
toFiniteNumber,
|
||||
parseCoinsPerMonth,
|
||||
parseCoinAmount,
|
||||
toDateSafe,
|
||||
addMonths,
|
||||
computeNextGrantTimestamp,
|
||||
getServerTimestamp,
|
||||
resolveStripeWebhookSecret,
|
||||
toFirestoreTimestamp,
|
||||
extractFirebaseUid,
|
||||
formatCoinPack,
|
||||
getSubscriptionMetaFromPrice,
|
||||
buildEventSnapshot,
|
||||
formatSubscriptionForClient,
|
||||
buildSubscriptionPayload,
|
||||
resolveUserContext,
|
||||
upsertPaymentDocument,
|
||||
COIN_PACK_PRODUCT_MAP,
|
||||
};
|
||||
Reference in New Issue
Block a user