home, subscription and other fixes
This commit is contained in:
@@ -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
@@ -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, {
|
||||
|
||||
Reference in New Issue
Block a user