stripe embedded and other fixes

This commit is contained in:
Thomas Demirdjian
2025-11-18 11:40:39 +01:00
parent 016f7c27ac
commit 7ab8685180
17 changed files with 2346 additions and 2504 deletions
+401
View File
@@ -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,
};