home, subscription and other fixes

This commit is contained in:
Thomas Demirdjian
2025-11-06 17:33:07 +01:00
parent 5f777f3b6d
commit 4365f1e46d
31 changed files with 1579 additions and 348 deletions
+82 -5
View File
@@ -13,6 +13,33 @@ const { sendNotification } = require("./notifications");
const DISTRIBUTION_REVENUE_BASELINE = 1000;
const DISTRIBUTION_RATIO = 0.3;
const ACTIVE_SUBSCRIPTION_STATUSES = new Set(["active"]);
const hasActiveSubscription = (userData) => {
if (!userData || typeof userData !== "object") {
return false;
}
const isPremium = userData.isPremium === true;
if (!isPremium) {
return false;
}
const status =
typeof userData.stripeSubscriptionStatus === "string"
? userData.stripeSubscriptionStatus.trim().toLowerCase()
: null;
if (status && ACTIVE_SUBSCRIPTION_STATUSES.has(status)) {
return true;
}
const billingPeriod =
typeof userData.premiumBillingPeriod === "string"
? userData.premiumBillingPeriod.trim().toLowerCase()
: null;
if (billingPeriod === "monthly" || billingPeriod === "annual") {
// Fallback: billing period is set only for active subscribers.
return true;
}
return false;
};
exports.distributeMonthlyPayouts = onSchedule(
{
@@ -51,6 +78,51 @@ exports.distributeMonthlyPayouts = onSchedule(
.orderBy(["streams"], ["desc"])
.value();
const userEligibilityMap = {};
const userIds = _.uniq(
entries.map((entry) => entry.userId).filter((userId) => !!userId),
);
if (userIds.length) {
const chunkSize = 300;
for (let index = 0; index < userIds.length; index += chunkSize) {
const chunk = userIds.slice(index, index + chunkSize);
const snapshots = await Promise.all(
chunk.map(async (userId) => {
try {
return await refList.users.doc(userId).get();
} catch (error) {
console.warn(
"[distributeMonthlyPayouts] Unable to load user profile",
{
userId,
error: error?.message || String(error),
},
);
return null;
}
}),
);
snapshots.forEach((snapshot, snapshotIndex) => {
const userId = chunk[snapshotIndex];
if (snapshot?.exists) {
userEligibilityMap[userId] = hasActiveSubscription(
snapshot.data(),
);
} else {
userEligibilityMap[userId] = false;
}
});
}
}
const eligibleEntries = entries.filter(
(entry) =>
!!entry.userId && userEligibilityMap[entry.userId] === true,
);
const eligibleTotalStreams = _.sumBy(eligibleEntries, "streams");
const payoutsTotalStreamsFromDocs = _.sumBy(entries, "streams");
const totalsData = totalsSnapshot.exists ? totalsSnapshot.data() : null;
let totalStreams = _.toFinite(_.get(totalsData, "totalStreams", 0));
@@ -63,9 +135,9 @@ exports.distributeMonthlyPayouts = onSchedule(
2,
);
let allocations = _.map(entries, (entry) => {
if (!totalStreams) return 0;
const rawAmount = (payoutPool * entry.streams) / totalStreams;
let allocations = _.map(eligibleEntries, (entry) => {
if (!eligibleTotalStreams) return 0;
const rawAmount = (payoutPool * entry.streams) / eligibleTotalStreams;
return _.round(rawAmount, 2);
});
@@ -77,12 +149,14 @@ exports.distributeMonthlyPayouts = onSchedule(
}
}
const payouts = entries.map((entry, idx) => ({
const payouts = eligibleEntries.map((entry, idx) => ({
rank: idx + 1,
projectId: entry.projectId,
userId: entry.userId,
streams: entry.streams,
share: totalStreams ? _.round(entry.streams / totalStreams, 6) : 0,
share: eligibleTotalStreams
? _.round(entry.streams / eligibleTotalStreams, 6)
: 0,
amount: allocations[idx],
statsDocPath: entry.statsDocPath,
}));
@@ -192,7 +266,10 @@ exports.distributeMonthlyPayouts = onSchedule(
payoutRatio: DISTRIBUTION_RATIO,
payoutPool,
totalStreams,
eligibleTotalStreams,
totalRecipients: payouts.length,
totalEntries: entries.length,
eligibleEntries: eligibleEntries.length,
totalAllocated: _.round(_.sumBy(payouts, "amount"), 2),
payouts,
status: payouts.length ? "computed" : "no-data",
+158 -16
View File
@@ -623,8 +623,12 @@ const handleCustomerSubscriptionEvent = async (
if (!userRef) {
console.warn(
"[subscription-handleCustomerSubscriptionEvent] User not resolved",
subscription.customer,
event?.type,
{
subscriptionId: subscription?.id || null,
customerId: subscription?.customer || null,
metadataKeys: Object.keys(subscription?.metadata || {}),
eventType: event?.type || null,
},
);
return;
}
@@ -797,10 +801,46 @@ const handleInvoiceEvent = async (invoice, event, { stripe } = {}) => {
const eventType = event?.type || null;
const paymentDocRef = paymentsCollection.doc(invoice.id);
let resolvedSubscriptionId =
typeof invoice.subscription === "string" && invoice.subscription
? invoice.subscription
: null;
if (!resolvedSubscriptionId) {
const lineSubscriptionId = Array.isArray(invoice?.lines?.data)
? invoice.lines.data
.map((line) =>
typeof line?.subscription === "string" && line.subscription
? line.subscription
: null,
)
.find((value) => value)
: null;
if (lineSubscriptionId) {
resolvedSubscriptionId = lineSubscriptionId;
console.log("[subscription-handleInvoiceEvent] Subscription resolved from invoice line", {
invoiceId: invoice?.id || null,
subscriptionId: resolvedSubscriptionId,
});
}
}
console.log("[subscription-handleInvoiceEvent] Received invoice webhook", {
eventType,
invoiceId: invoice?.id || null,
subscriptionId: invoice?.subscription || null,
resolvedSubscriptionId: resolvedSubscriptionId || null,
customerId: invoice?.customer || null,
status: invoice?.status || null,
billingReason: invoice?.billing_reason || null,
attemptCount: invoice?.attempt_count ?? null,
});
await upsertPaymentDocument(invoice.id, {
userId: firebaseUid || null,
customerId: invoice.customer || null,
subscriptionId: invoice.subscription || null,
subscriptionId: resolvedSubscriptionId,
status: invoice.status || null,
paymentStatus:
eventType === "invoice.payment_failed"
@@ -834,6 +874,12 @@ const handleInvoiceEvent = async (invoice, event, { stripe } = {}) => {
});
if (!userRef) {
console.warn("[subscription-handleInvoiceEvent] User context not resolved", {
invoiceId: invoice?.id || null,
customerId: invoice?.customer || null,
firebaseUid: firebaseUid || null,
metadataKeys: Object.keys(invoice?.metadata || {}),
});
return;
}
@@ -862,10 +908,47 @@ const handleInvoiceEvent = async (invoice, event, { stripe } = {}) => {
const billingReason = invoice.billing_reason || null;
const isInvoicePaid = invoice.status === "paid";
const subscriptionInvoiceReasons = ["subscription_cycle", "subscription_create"];
const isSubscriptionBillingReason =
subscriptionInvoiceReasons.includes(billingReason);
if (
isSubscriptionBillingReason &&
!resolvedSubscriptionId &&
stripe &&
typeof invoice.id === "string"
) {
try {
const fetchedInvoice = await stripe.invoices.retrieve(invoice.id);
const fetchedSubscriptionId =
typeof fetchedInvoice?.subscription === "string" &&
fetchedInvoice.subscription
? fetchedInvoice.subscription
: typeof fetchedInvoice?.subscription?.id === "string" &&
fetchedInvoice.subscription.id
? fetchedInvoice.subscription.id
: null;
if (fetchedSubscriptionId) {
resolvedSubscriptionId = fetchedSubscriptionId;
console.log(
"[subscription-handleInvoiceEvent] Subscription resolved from fetched invoice",
{
invoiceId: invoice.id || null,
subscriptionId: resolvedSubscriptionId,
},
);
}
} catch (error) {
console.warn(
"[subscription-handleInvoiceEvent] Unable to fetch invoice to resolve subscription",
invoice.id,
error?.message || error,
);
}
}
const isSubscriptionInvoice =
["subscription_cycle", "subscription_create"].includes(billingReason) &&
typeof invoice.subscription === "string" &&
invoice.subscription;
isSubscriptionBillingReason && Boolean(resolvedSubscriptionId);
const shouldProcessAllowance =
eventType === "invoice.paid" && isInvoicePaid && isSubscriptionInvoice;
@@ -875,7 +958,7 @@ const handleInvoiceEvent = async (invoice, event, { stripe } = {}) => {
console.log("[subscription-handleInvoiceEvent] Processing subscription allowance", {
invoiceId: invoice.id || null,
subscriptionId: invoice.subscription || null,
subscriptionId: resolvedSubscriptionId || null,
customer: invoice.customer || null,
billingReason,
orderTargetUid,
@@ -917,6 +1000,21 @@ const handleInvoiceEvent = async (invoice, event, { stripe } = {}) => {
if (expandedPrice && typeof expandedPrice === "object") {
stripePrice = expandedPrice;
}
if (
!resolvedSubscriptionId &&
typeof expandedInvoice?.subscription === "string" &&
expandedInvoice.subscription
) {
resolvedSubscriptionId = expandedInvoice.subscription;
console.log(
"[subscription-handleInvoiceEvent] Subscription resolved from expanded invoice",
{
invoiceId: invoice.id || null,
subscriptionId: resolvedSubscriptionId,
},
);
}
} catch (error) {
console.warn(
"[subscription-handleInvoiceEvent] Unable to expand invoice price",
@@ -932,6 +1030,21 @@ const handleInvoiceEvent = async (invoice, event, { stripe } = {}) => {
};
const stripePrice = await loadInvoicePrice();
console.log("[subscription-handleInvoiceEvent] Loaded invoice price", {
invoiceId: invoice.id || null,
subscriptionId: resolvedSubscriptionId || null,
priceId: stripePrice?.id || null,
productId:
(typeof stripePrice?.product === "string"
? stripePrice.product
: stripePrice?.product?.id) || null,
hasProductMetadata:
!!(
stripePrice?.product &&
typeof stripePrice.product === "object" &&
Object.keys(stripePrice.product.metadata || {}).length > 0
),
});
let coinsPerMonth = parseCoinsPerMonth(
(stripePrice?.product && typeof stripePrice.product === "object"
? stripePrice.product.metadata
@@ -967,7 +1080,7 @@ const handleInvoiceEvent = async (invoice, event, { stripe } = {}) => {
"[subscription-handleInvoiceEvent] Using static allowance from price metadata",
{
invoiceId: invoice.id || null,
subscriptionId: invoice.subscription || null,
subscriptionId: resolvedSubscriptionId || null,
priceId: invoicePriceId,
coinsPerMonth,
},
@@ -985,7 +1098,7 @@ const handleInvoiceEvent = async (invoice, event, { stripe } = {}) => {
"[subscription-handleInvoiceEvent] Using allowance from invoice metadata",
{
invoiceId: invoice.id || null,
subscriptionId: invoice.subscription || null,
subscriptionId: resolvedSubscriptionId || null,
priceId: invoicePriceId,
coinsPerMonth,
},
@@ -1001,7 +1114,7 @@ const handleInvoiceEvent = async (invoice, event, { stripe } = {}) => {
"[subscription-handleInvoiceEvent] Using allowance from user profile cache",
{
invoiceId: invoice.id || null,
subscriptionId: invoice.subscription || null,
subscriptionId: resolvedSubscriptionId || null,
priceId: invoicePriceId,
coinsPerMonth,
},
@@ -1009,6 +1122,17 @@ const handleInvoiceEvent = async (invoice, event, { stripe } = {}) => {
}
}
if (coinsPerMonth === null) {
console.warn("[subscription-handleInvoiceEvent] Missing coinsPerMonth allowance", {
invoiceId: invoice.id || null,
subscriptionId: resolvedSubscriptionId || null,
priceId: invoicePriceId || null,
userId: userRef.id,
hasStoredAllowance: Boolean(userData?.subscriptionCoinsPerMonth),
metadataCoins: invoice.metadata?.coinsPerMonth ?? null,
});
}
const recurringInterval = stripePrice?.recurring?.interval || null;
let billingPeriod = userData?.premiumBillingPeriod || null;
if (recurringInterval === "month") {
@@ -1022,7 +1146,7 @@ const handleInvoiceEvent = async (invoice, event, { stripe } = {}) => {
console.log("[subscription-handleInvoiceEvent] Allowance context", {
invoiceId: invoice.id || null,
subscriptionId: invoice.subscription || null,
subscriptionId: resolvedSubscriptionId || null,
coinsPerMonth,
billingPeriod,
recurringInterval,
@@ -1033,7 +1157,7 @@ const handleInvoiceEvent = async (invoice, event, { stripe } = {}) => {
if (coinsPerMonth && coinsPerMonth > 0 && orderTargetUid) {
console.log("[subscription-handleInvoiceEvent] Preparing coin grant", {
invoiceId: invoice.id || null,
subscriptionId: invoice.subscription || null,
subscriptionId: resolvedSubscriptionId || null,
userId: orderTargetUid,
coinsPerMonth,
billingPeriod,
@@ -1063,7 +1187,7 @@ const handleInvoiceEvent = async (invoice, event, { stripe } = {}) => {
source: "STRIPE_SUBSCRIPTION",
schedule: isAnnual ? "annual_invoice" : "monthly_invoice",
invoiceId: invoice.id,
subscriptionId: invoice.subscription || null,
subscriptionId: resolvedSubscriptionId || null,
billingPeriod,
billingReason,
},
@@ -1085,7 +1209,7 @@ const handleInvoiceEvent = async (invoice, event, { stripe } = {}) => {
console.log("[subscription-handleInvoiceEvent] Subscription coins granted", {
orderId: processedOrderId,
invoiceId: invoice.id,
subscriptionId: invoice.subscription || null,
subscriptionId: resolvedSubscriptionId || null,
userId: orderTargetUid,
amount: coinsPerMonth,
billingPeriod,
@@ -1131,14 +1255,14 @@ const handleInvoiceEvent = async (invoice, event, { stripe } = {}) => {
} else {
console.log("[subscription-handleInvoiceEvent] Coins already granted for invoice", {
invoiceId: invoice.id,
subscriptionId: invoice.subscription || null,
subscriptionId: resolvedSubscriptionId || null,
userId: orderTargetUid,
});
}
} else {
console.log("[subscription-handleInvoiceEvent] Skipping allowance grant", {
invoiceId: invoice.id || null,
subscriptionId: invoice.subscription || null,
subscriptionId: resolvedSubscriptionId || null,
coinsPerMonth,
orderTargetUid,
billingPeriod,
@@ -1146,6 +1270,17 @@ const handleInvoiceEvent = async (invoice, event, { stripe } = {}) => {
}
}
if (!shouldProcessAllowance) {
console.log("[subscription-handleInvoiceEvent] Allowance conditions not met", {
eventType,
invoiceStatus: invoice.status || null,
billingReason,
hasSubscription: Boolean(resolvedSubscriptionId),
isSubscriptionInvoice,
isInvoicePaid,
});
}
await userRef.set(userUpdate, { merge: true });
};
@@ -1208,6 +1343,13 @@ const handleStripeWebhookEvent = async ({ event, stripe }) => {
return;
}
console.log("[subscription-handleStripeWebhookEvent] Received event", {
id: event?.id || null,
type: event?.type || null,
apiVersion: event?.api_version || null,
created: event?.created || null,
});
switch (event.type) {
case "checkout.session.completed":
await handleCheckoutSessionCompleted(event.data?.object, event, {