diff --git a/packages/api/src/config.js b/packages/api/src/config.js index 36fa823a4b..32fc2d71d4 100644 --- a/packages/api/src/config.js +++ b/packages/api/src/config.js @@ -1,5 +1,6 @@ exports.products = { prod_0: { + deprecated: true, order: 0, name: "Personal", lookupKeys: ["price_0"], @@ -8,10 +9,25 @@ exports.products = { name: "Transcoding", description: "Transcoding (minutes)", price: 0.0, + limit: 1_000, + }, + { + name: "Delivery", + description: "Delivery (minutes)", + price: 0.0, + limit: 1_000, + }, + { + name: "Storage", + description: "Storage (minutes)", + price: 0.0, + limit: 1_000, }, ], + monthlyPrice: 0, }, prod_1: { + deprecated: true, order: 1, name: "Pro", lookupKeys: ["price_1"], @@ -19,11 +35,26 @@ exports.products = { { name: "Transcoding", description: "Transcoding (minutes)", - price: 0.005, + price: 0.0055, + limit: 3_000, + }, + { + name: "Delivery", + description: "Delivery (minutes)", + price: 0.0005, + limit: 100_000, + }, + { + name: "Storage", + description: "Storage (minutes)", + price: 0.0035, + limit: 10_000, }, ], + monthlyPrice: 0, }, prod_2: { + deprecated: true, order: 2, name: "Business", lookupKeys: ["price_2"], @@ -34,28 +65,34 @@ exports.products = { price: 0.0, }, ], + monthlyPrice: 0, }, hacker_1: { order: 3, name: "Hacker", lookupKeys: ["hacker_1"], + price: 0, usage: [ { name: "Transcoding", description: "Transcoding (minutes)", price: 0, + limit: 1_000, }, { name: "Delivery", description: "Delivery (minutes)", price: 0, + limit: 1_000, }, { name: "Storage", description: "Storage (minutes)", price: 0, + limit: 1_000, }, ], + monthlyPrice: 0, }, growth_1: { order: 4, @@ -65,19 +102,24 @@ exports.products = { { name: "Transcoding", description: "Transcoding (minutes)", - price: 0, + price: 0.0055, + limit: 3_000, }, { name: "Delivery", description: "Delivery (minutes)", - price: 0, + price: 0.0005, + limit: 100_000, }, { name: "Storage", description: "Storage (minutes)", - price: 0, + price: 0.0035, + limit: 10_000, }, ], + monthlyPrice: 100, + payAsYouGo: true, }, scale_1: { order: 5, @@ -87,19 +129,24 @@ exports.products = { { name: "Transcoding", description: "Transcoding (minutes)", - price: 0, + price: 0.0055, + limit: 20_000, }, { name: "Delivery", description: "Delivery (minutes)", - price: 0, + price: 0.0005, + limit: 500_000, }, { name: "Storage", description: "Storage (minutes)", - price: 0, + price: 0.0035, + limit: 50_000, }, ], + monthlyPrice: 500, + payAsYouGo: true, }, pay_as_you_go_1: { order: 6, @@ -109,21 +156,23 @@ exports.products = { { name: "Transcoding", description: "Transcoding (minutes)", - price: 0.005, + price: 0.0055, }, { name: "Delivery", description: "Delivery (minutes)", - price: 0.0004, + price: 0.0005, }, { name: "Storage", description: "Storage (minutes)", - price: 0.003, + price: 0.0035, }, ], + monthlyPrice: 0, }, prod_4: { + deprecated: true, order: 7, name: "Enterprise", lookupKeys: ["enterprise_1"], @@ -144,5 +193,109 @@ exports.products = { price: 0, }, ], + monthlyPrice: 0, + }, + prod_O9XuIjn7EqYRVW: { + order: 8, + name: "Hacker", + lookupKeys: ["hacker_1"], + usage: [ + { + name: "Transcoding", + description: "Transcoding (minutes)", + price: 0, + limit: 1_000, + }, + { + name: "Delivery", + description: "Delivery (minutes)", + price: 0, + limit: 1_000, + }, + { + name: "Storage", + description: "Storage (minutes)", + price: 0, + limit: 1_000, + }, + ], + monthlyPrice: 0, + }, + prod_O9XtHhI6rbTT1B: { + order: 9, + name: "Growth", + lookupKeys: ["growth_1"], + usage: [ + { + name: "Transcoding", + description: "Transcoding (minutes)", + price: 0.0055, + limit: 3_000, + }, + { + name: "Delivery", + description: "Delivery (minutes)", + price: 0.0005, + limit: 100_000, + }, + { + name: "Storage", + description: "Storage (minutes)", + price: 0.0035, + limit: 10_000, + }, + ], + monthlyPrice: 100, + payAsYouGo: true, + }, + prod_O9XtcfOSMjSD5L: { + order: 10, + name: "Scale", + lookupKeys: ["scale_1"], + usage: [ + { + name: "Transcoding", + description: "Transcoding (minutes)", + price: 0.0055, + limit: 20_000, + }, + { + name: "Delivery", + description: "Delivery (minutes)", + price: 0.0005, + limit: 500_000, + }, + { + name: "Storage", + description: "Storage (minutes)", + price: 0.0035, + limit: 50_000, + }, + ], + monthlyPrice: 500, + payAsYouGo: true, + }, + prod_O9XuWMU1Up6QKf: { + order: 11, + name: "Pay-As-You-Go", + lookupKeys: ["transcoding_usage", "tstreaming_usage", "tstorage_usage"], + usage: [ + { + name: "Transcoding", + description: "Transcoding (minutes)", + price: 0.0055, + }, + { + name: "Delivery", + description: "Delivery (minutes)", + price: 0.0005, + }, + { + name: "Storage", + description: "Storage (minutes)", + price: 0.0035, + }, + ], + monthlyPrice: 0, }, }; diff --git a/packages/api/src/controllers/helpers.ts b/packages/api/src/controllers/helpers.ts index c4de1738cd..d7dbfd2352 100644 --- a/packages/api/src/controllers/helpers.ts +++ b/packages/api/src/controllers/helpers.ts @@ -296,6 +296,29 @@ export async function sendgridEmail({ await SendgridMail.send(msg); } +export async function sendgridEmailPaymentFailed({ + email, + sendgridApiKey, + userId, + invoiceId, +}) { + const [supportName, supportEmail] = email; + const msg = { + text: `User ${userId} failed to pay invoice ${invoiceId}`, + from: { + email: supportEmail, + name: supportName, + }, + reply_to: { + email: supportEmail, + name: supportName, + }, + }; + + SendgridMail.setApiKey(sendgridApiKey); + await SendgridMail.send(msg); +} + export function sendgridValidateEmail(email: string, validationApiKey: string) { if (!validationApiKey) { return; diff --git a/packages/api/src/controllers/stripe.js b/packages/api/src/controllers/stripe.js deleted file mode 100644 index e25d968e18..0000000000 --- a/packages/api/src/controllers/stripe.js +++ /dev/null @@ -1,137 +0,0 @@ -import Router from "express/lib/router"; -import { db } from "../store"; -import Stripe from "stripe"; -import { products } from "../config"; -import sql from "sql-template-strings"; - -const app = Router(); - -// Webhook handler for asynchronous events. -app.post("/webhook", async (req, res) => { - const sig = req.headers["stripe-signature"]; - - let event; - - try { - const secret = req.config.stripeWebhookSecret; - event = req.stripe.webhooks.constructEvent(req.body, sig, secret); - } catch (err) { - return res.status(400).send(`Webhook Error: ${err.message}`); - } - - if (event.type === "invoice.created") { - let invoice = event.data.object; - - if (invoice.status !== "draft") { - // we don't need to do anything - return res.sendStatus(200); - } - - const [users] = await db.user.find( - { stripeCustomerId: invoice.customer }, - { useReplica: false } - ); - - if (users.length < 1) { - res.status(404); - return res.json({ errors: ["user not found"] }); - } - - const user = users[0]; - const usageRes = await db.stream.usage( - user.id, - invoice.period_start, - invoice.period_end, - { - useReplica: false, - } - ); - - // Invoice items based on usage - await Promise.all( - products[user.stripeProductId].usage.map(async (product) => { - if (product.name === "Transcoding") { - let quantity = Math.round(usageRes.sourceSegmentsDuration / 60); - await req.stripe.invoiceItems.create({ - customer: user.stripeCustomerId, - invoice: invoice.id, - currency: "usd", - period: { - start: invoice.period_start, - end: invoice.period_end, - }, - subscription: user.stripeCustomerSubscriptionId, - unit_amount_decimal: product.price * 100, - quantity, - description: product.description, - }); - } - }) - ); - } - - // Return a response to acknowledge receipt of the event - return res.sendStatus(200); -}); - -async function sleep(millis) { - return new Promise((resolve) => setTimeout(resolve, millis)); -} - -// Migrate existing users to stripe -app.post("/migrate-users-to-stripe", async (req, res) => { - if (req.config.stripeSecretKey != req.body.stripeSecretKey) { - res.status(403); - return res.json({ errors: ["unauthorized"] }); - } - - const [users] = await db.user.find( - [sql`users.data->>'stripeCustomerId' IS NULL`], - { - limit: 9999999999, - useReplica: false, - } - ); - - for (let index = 0; index < users.length; index++) { - let user = users[index]; - - const { data } = await req.stripe.customers.list({ - email: user.email, - }); - - if (data.length === 0) { - // create the stripe customer - const customer = await req.stripe.customers.create({ - email: user.email, - }); - - // fetch prices associated with free plan - const items = await req.stripe.prices.list({ - lookup_keys: products["prod_0"].lookupKeys, - }); - - // Subscribe the user to the free plan - const subscription = await req.stripe.subscriptions.create({ - cancel_at_period_end: false, - customer: customer.id, - items: items.data.map((item) => ({ price: item.id })), - }); - - // Update user's customer, product, subscription, and payment id in our db - await db.user.update(user.id, { - stripeCustomerId: customer.id, - stripeProductId: "prod_0", - stripeCustomerSubscriptionId: subscription.id, - stripeCustomerPaymentMethodId: null, - }); - - // sleep for a 200 ms to get around stripe rate limits - await sleep(200); - } - } - - res.json(users); -}); - -export default app; diff --git a/packages/api/src/controllers/stripe.ts b/packages/api/src/controllers/stripe.ts new file mode 100644 index 0000000000..f356a819f7 --- /dev/null +++ b/packages/api/src/controllers/stripe.ts @@ -0,0 +1,796 @@ +import Router from "express/lib/router"; +import { db } from "../store"; +import Stripe from "stripe"; +import { products } from "../config"; +import sql from "sql-template-strings"; +import fetch from "node-fetch"; +import qs from "qs"; +import { sendgridEmailPaymentFailed } from "./helpers"; + +const app = Router(); + +const testProducts = ["hacker_1", "growth_1", "scale_1"]; + +const productMapping = { + hacker_1: "prod_O9XuIjn7EqYRVW", + growth_1: "prod_O9XtHhI6rbTT1B", + scale_1: "prod_O9XtcfOSMjSD5L", +}; + +export const reportUsage = async (req, adminToken) => { + const [users] = await db.user.find( + [ + sql`users.data->>'stripeProductId' IN ('growth_1', 'scale_1', 'prod_O9XtHhI6rbTT1B','prod_O9XtcfOSMjSD5L')`, + ], + { + limit: 9999999999, + useReplica: true, + } + ); + + // This is for old users who are on the pro plan + // They are on the hacker plan after migration, with pay as you go enabled + // We need to report usage for them as well + const [oldProPlanUsers] = await db.user.find( + [ + sql`users.data->>'oldProPlan' = true AND users.data->>'stripeProductId' IN ('hacker_1','prod_O9XuIjn7EqYRVW')`, + ], + { + limit: 9999999999, + useReplica: true, + } + ); + + const payAsYouGoUsers = [...users, ...oldProPlanUsers]; + + let updatedUsers = []; + for (const user of payAsYouGoUsers) { + try { + let userUpdated = await reportUsageForUser(req, user, adminToken); + updatedUsers.push(userUpdated); + } catch (e) { + console.log(` + Failed to create usage record for user=${user.id} with error=${e.message} - it's pay as you go subscription probably needs to get migrated + `); + updatedUsers.push({ + id: user.id, + email: user.email, + usageReported: false, + error: e.message, + }); + } + } + + return { + updatedUsers: updatedUsers, + }; +}; + +async function reportUsageForUser(req, user, adminToken) { + const userSubscription = await req.stripe.subscriptions.retrieve( + user.stripeCustomerSubscriptionId + ); + + const billingCycleStart = userSubscription.current_period_start * 1000; // 1685311200000 // Test date + const billingCycleEnd = userSubscription.current_period_end * 1000; // 1687989600000 // Test date + + const ingests = await req.getIngest(); + const billingUsage = await getBillingUsage( + user.id, + billingCycleStart, + billingCycleEnd, + ingests[0].origin, + adminToken + ); + + const overUsage = await calculateOverUsage( + products[user.stripeProductId], + billingUsage + ); + + const subscriptionItems = await req.stripe.subscriptionItems.list({ + subscription: user.stripeCustomerSubscriptionId, + }); + + // create a map of subscription items by their lookup keys + const subscriptionItemsByLookupKey = subscriptionItems.data.reduce( + (acc, item) => { + acc[item.price.lookup_key] = item.id; + return acc; + }, + {} as Record + ); + + // Invoice items based on overusage + await Promise.all( + products[user.stripeProductId].usage.map(async (product) => { + if (product.name === "Transcoding") { + await req.stripe.subscriptionItems.createUsageRecord( + subscriptionItemsByLookupKey["transcoding_usage"], + { + quantity: parseInt(overUsage.TotalUsageMins.toFixed(0)), + timestamp: Math.floor(new Date().getTime() / 1000), + action: "set", + } + ); + } else if (product.name === "Delivery") { + await req.stripe.subscriptionItems.createUsageRecord( + subscriptionItemsByLookupKey["tstreaming_usage"], + { + quantity: parseInt(overUsage.DeliveryUsageMins.toFixed(0)), + timestamp: Math.floor(new Date().getTime() / 1000), + action: "set", + } + ); + } else if (product.name === "Storage") { + await req.stripe.subscriptionItems.createUsageRecord( + subscriptionItemsByLookupKey["tstorage_usage"], + { + quantity: parseInt(overUsage.StorageUsageMins.toFixed(0)), + timestamp: Math.floor(new Date().getTime() / 1000), + action: "set", + } + ); + } + }) + ); + return { + id: user.id, + email: user.email, + overUsage: overUsage, + usage: billingUsage, + usageReported: true, + }; +} + +const calculateOverUsage = async (product, usage) => { + let limits: any = {}; + + if (product?.usage) { + product.usage.forEach((item) => { + if (item.name.toLowerCase() === "transcoding") + limits.transcoding = item.limit; + if (item.name.toLowerCase() === "delivery") limits.streaming = item.limit; + if (item.name.toLowerCase() === "storage") limits.storage = item.limit; + }); + } + + const overUsage = { + TotalUsageMins: Math.max( + usage?.TotalUsageMins - (limits.transcoding || 0), + 0 + ), + DeliveryUsageMins: Math.max( + usage?.DeliveryUsageMins - (limits.streaming || 0), + 0 + ), + StorageUsageMins: Math.max( + usage?.StorageUsageMins - (limits.storage || 0), + 0 + ), + }; + + return overUsage; +}; + +const getBillingUsage = async ( + userId, + fromTime, + toTime, + baseUrl, + adminToken +) => { + // Fetch usage data from /data/usage endpoint + const usage = await fetch( + `${baseUrl}/api/data/usage/query?${qs.stringify({ + from: fromTime, + to: toTime, + userId: userId, + })}`, + { + headers: { + Authorization: `Bearer ${adminToken}`, + }, + } + ).then((res) => res.json()); + + return usage; +}; + +// Webhook handler for asynchronous events called by stripe on invoice generation +// https://stripe.com/docs/billing/subscriptions/webhooks +app.post("/webhook", async (req, res) => { + const sig = req.headers["stripe-signature"]; + + let event; + + try { + const secret = req.config.stripeWebhookSecret; + event = req.stripe.webhooks.constructEvent(req.body, sig, secret); + } catch (err) { + return res.status(400).send(`Webhook Error: ${err.message}`); + } + + if (event.type === "invoice.created") { + let invoice = event.data.object; + + if (invoice.status !== "draft") { + // we don't need to do anything + return res.sendStatus(200); + } + + const [users] = await db.user.find( + { stripeCustomerId: invoice.customer }, + { useReplica: false } + ); + + if (users.length < 1) { + res.status(404); + return res.json({ errors: ["user not found"] }); + } + + const user = users[0]; + + const usageRes = await db.stream.usage( + user.id, + invoice.period_start, + invoice.period_end, + { + useReplica: false, + } + ); + + // Invoice items based on usage + await Promise.all( + products[user.stripeProductId].usage.map(async (product) => { + if (product.name === "Transcoding") { + let quantity = Math.round(usageRes.sourceSegmentsDuration / 60); + await req.stripe.invoiceItems.create({ + customer: user.stripeCustomerId, + invoice: invoice.id, + currency: "usd", + period: { + start: invoice.period_start, + end: invoice.period_end, + }, + subscription: user.stripeCustomerSubscriptionId, + }); + } + }) + ); + } else if (event.type === "invoice.payment_failed") { + // Notify via email + const invoice = event.data.object; + const [users] = await db.user.find( + { stripeCustomerId: invoice.customer }, + { useReplica: false } + ); + + // notify help@livepeer.org + await sendgridEmailPaymentFailed({ + email: "help@livepeer.org", + sendgridApiKey: req.config.sendgridApiKey, + userId: users[0].id, + invoiceId: invoice.id, + }); + } + + // Return a response to acknowledge receipt of the event + return res.sendStatus(200); +}); + +async function sleep(millis) { + return new Promise((resolve) => setTimeout(resolve, millis)); +} + +app.post("/migrate-personal-user", async (req, res) => { + if (req.config.stripeSecretKey != req.body.stripeSecretKey) { + res.status(403); + return res.json({ errors: ["unauthorized"] }); + } + let migration = []; + + const [users] = await db.user.find( + [ + sql`users.data->>'stripeProductId' = 'prod_0' + AND (users.data->>'isActiveSubscription' = 'true' OR users.data->>'isActiveSubscription' IS NULL) + AND (users.data->>'newStripeProductId' IS NULL OR (users.data->>'newStripeProductId' != 'growth_1' AND users.data->>'newStripeProductId' != 'scale_1'))`, + ], + { + limit: 1, + useReplica: false, + } + ); + + if (users.length == 0) { + res.json({ + errors: ["no users found"], + }); + return; + } + + migration.push(`Found ${users.length} users to migrate`); + + let user = users[0]; + + migration.push(`Migrating user ${user.email}`); + + const { data } = await req.stripe.customers.list({ + email: user.email, + }); + + if (data.length > 0) { + if ( + user.stripeProductId === "prod_0" && + user.newStripeProductId !== "growth_1" && + user.newStripeProductId !== "scale_1" + ) { + migration.push( + `User ${user.email} is migrating from ${user.stripeProductId} to prod_O9XuIjn7EqYRVW` + ); + const items = await req.stripe.prices.list({ + lookup_keys: products["prod_O9XuIjn7EqYRVW"].lookupKeys, + }); + let subscription; + + try { + subscription = await req.stripe.subscriptions.retrieve( + user.stripeCustomerSubscriptionId + ); + migration.push( + `User ${user.email} subscription is ${subscription.status}` + ); + } catch (e) { + console.log(` + Unable to migrate personal user - subscription not found for user=${user.id} email=${user.email} subscriptionId=${user.stripeCustomerSubscriptionId} + `); + await db.user.update(user.id, { + isActiveSubscription: false, + }); + res.json({ + errors: [ + `Unable to migrate personal user - subscription not found for user=${user.id} email=${user.email} subscriptionId=${user.stripeCustomerSubscriptionId}`, + ], + }); + return; + } + + if (subscription.status != "active") { + console.log(` + Unable to migrate personal user - user=${user.id} has a status=${subscription.status} subscription + `); + await db.user.update(user.id, { + isActiveSubscription: false, + }); + res.json({ + errors: [ + `Unable to migrate personal user - user=${user.id} has a status=${subscription.status} subscription`, + ], + }); + return; + } + + try { + const subscriptionItems = await req.stripe.subscriptionItems.list({ + subscription: user.stripeCustomerSubscriptionId, + }); + + migration.push( + `User ${user.email} has ${subscriptionItems.data.length} subscription items` + ); + + subscription = await req.stripe.subscriptions.update( + user.stripeCustomerSubscriptionId, + { + billing_cycle_anchor: "now", + cancel_at_period_end: false, + items: [ + ...subscriptionItems.data.map((item) => { + // Check if the item is metered + const isMetered = item.price.recurring.usage_type === "metered"; + return { + id: item.id, + deleted: true, + clear_usage: isMetered ? true : undefined, // If metered, clear usage + price: item.price.id, + }; + }), + ...items.data.map((item) => ({ + price: item.id, + })), + ], + } + ); + + migration.push( + `User ${user.email} has been updated to ${subscription.id}` + ); + + await db.user.update(user.id, { + stripeCustomerId: user.stripeCustomerId, + stripeProductId: "prod_O9XuIjn7EqYRVW", + stripeCustomerSubscriptionId: subscription.id, + }); + + migration.push(`User ${user.email} has been updated in the database`); + } catch (e) { + console.log(` + Unable to migrate personal user - cannot update the user subscription or update the user data user=${user.id} email=${user.email} subscriptionId=${user.stripeCustomerSubscriptionId} + `); + res.json({ + errors: [ + `Unable to migrate personal user - cannot update the user subscription or update the user data user=${user.id} email=${user.email} subscriptionId=${user.stripeCustomerSubscriptionId}`, + ], + }); + return; + } + } + } else { + res.json({ + errors: [ + `Unable to migrate personal user - customer not found for user=${user.id} email=${user.email} subscriptionId=${user.stripeCustomerSubscriptionId}`, + ], + }); + await db.user.update(user.id, { + isActiveSubscription: false, + }); + return; + } + migration.push( + `User ${user.email} has been migrated to ${user.newStripeProductId}` + ); + res.json({ + result: "Migrated user with email " + user.email + " to hacker plan", + migration: migration, + }); +}); + +app.post("/migrate-hacker-user", async (req, res) => { + if (req.config.stripeSecretKey != req.body.stripeSecretKey) { + res.status(403); + return res.json({ errors: ["unauthorized"] }); + } + + const [users] = await db.user.find( + [ + sql`users.data->>'stripeProductId' = 'prod_O9XuIjn7EqYRVW' AND (users.data->>'isActiveSubscription' = 'true' OR users.data->>'isActiveSubscription' IS NULL)`, + ], + { + limit: 1, + useReplica: false, + } + ); + + if (users.length == 0) { + res.json({ + errors: ["no users found"], + }); + return; + } + + let user = users[0]; + + const { data } = await req.stripe.customers.list({ + email: user.email, + }); + + if (data.length > 0) { + if (user.stripeProductId === "prod_O9XuIjn7EqYRVW") { + const items = await req.stripe.prices.list({ + lookup_keys: products["prod_0"].lookupKeys, + }); + let subscription; + + try { + subscription = await req.stripe.subscriptions.retrieve( + user.stripeCustomerSubscriptionId + ); + } catch (e) { + console.log(` + Unable to migrate hacker user - subscription not found for user=${user.id} email=${user.email} subscriptionId=${user.stripeCustomerSubscriptionId} + `); + await db.user.update(user.id, { + isActiveSubscription: false, + }); + res.json({ + errors: [ + `Unable to migrate hacker user - subscription not found for user=${user.id} email=${user.email} subscriptionId=${user.stripeCustomerSubscriptionId}`, + ], + }); + return; + } + + if (subscription.status != "active") { + console.log(` + Unable to migrate hacker user - user=${user.id} has a status=${subscription.status} subscription + `); + await db.user.update(user.id, { + isActiveSubscription: false, + }); + res.json({ + errors: [ + `Unable to migrate hacker user - user=${user.id} has a status=${subscription.status} subscription`, + ], + }); + return; + } + + const subscriptionItems = await req.stripe.subscriptionItems.list({ + subscription: user.stripeCustomerSubscriptionId, + }); + + subscription = await req.stripe.subscriptions.update( + user.stripeCustomerSubscriptionId, + { + billing_cycle_anchor: "now", + cancel_at_period_end: false, + items: [ + ...subscriptionItems.data.map((item) => { + // Check if the item is metered + const isMetered = item.price.recurring.usage_type === "metered"; + return { + id: item.id, + deleted: true, + clear_usage: isMetered ? true : undefined, // If metered, clear usage + price: item.price.id, + }; + }), + ...items.data.map((item) => ({ + price: item.id, + })), + ], + } + ); + + await db.user.update(user.id, { + stripeCustomerId: user.stripeCustomerId, + stripeProductId: "prod_0", + stripeCustomerSubscriptionId: subscription.id, + }); + } + } else { + res.json({ + errors: [ + `Unable to migrate hacker user - customer not found for user=${user.id} email=${user.email} subscriptionId=${user.stripeCustomerSubscriptionId}`, + ], + }); + await db.user.update(user.id, { + isActiveSubscription: false, + }); + return; + } + res.json({ + result: "Migrated user with email " + user.email + " to personal plan", + }); +}); + +app.post("/migrate-pro-user", async (req, res) => { + if (req.config.stripeSecretKey != req.body.stripeSecretKey) { + res.status(403); + return res.json({ errors: ["unauthorized"] }); + } + + const [users] = await db.user.find( + [ + sql`users.data->>'stripeProductId' = 'prod_1' AND (users.data->>'isActiveSubscription' = 'true' OR users.data->>'isActiveSubscription' IS NULL)`, + ], + { + limit: 1, + useReplica: false, + } + ); + + if (users.length == 0) { + res.json({ + errors: ["no users found"], + }); + return; + } + + let user = users[0]; + + const { data } = await req.stripe.customers.list({ + email: user.email, + }); + + if (data.length > 0) { + if ( + user.stripeProductId === "prod_1" && + user.newStripeProductId !== "growth_1" && + user.newStripeProductId !== "scale_1" + ) { + const items = await req.stripe.prices.list({ + lookup_keys: products["prod_O9XuIjn7EqYRVW"].lookupKeys, + }); + let subscription; + + try { + subscription = await req.stripe.subscriptions.retrieve( + user.stripeCustomerSubscriptionId + ); + } catch (e) { + console.log(` + Unable to migrate pro user - subscription not found for user=${user.id} email=${user.email} subscriptionId=${user.stripeCustomerSubscriptionId} + `); + await db.user.update(user.id, { + isActiveSubscription: false, + }); + res.json({ + errors: [ + `Unable to migrate pro user - subscription not found for user=${user.id} email=${user.email} subscriptionId=${user.stripeCustomerSubscriptionId}`, + ], + }); + return; + } + + if (subscription.status != "active") { + console.log(` + Unable to migrate pro user - user=${user.id} has a status=${subscription.status} subscription + `); + await db.user.update(user.id, { + isActiveSubscription: false, + }); + res.json({ + errors: [ + `Unable to migrate pro user - user=${user.id} has a status=${subscription.status} subscription`, + ], + }); + return; + } + + const subscriptionItems = await req.stripe.subscriptionItems.list({ + subscription: user.stripeCustomerSubscriptionId, + }); + + let payAsYouGoItems = []; + if (products[user.stripeProductId].payAsYouGo) { + const payAsYouGoPrices = await req.stripe.prices.list({ + lookup_keys: products["pay_as_you_go_1"].lookupKeys, + }); + payAsYouGoItems = payAsYouGoPrices.data.map((item) => ({ + price: item.id, + })); + } + + subscription = await req.stripe.subscriptions.update( + user.stripeCustomerSubscriptionId, + { + billing_cycle_anchor: "now", + cancel_at_period_end: false, + items: [ + ...subscriptionItems.data.map((item) => { + const isMetered = item.price.recurring.usage_type === "metered"; + return { + id: item.id, + deleted: true, + clear_usage: isMetered ? true : undefined, + price: item.price.id, + }; + }), + ...items.data.map((item) => ({ + price: item.id, + })), + ...payAsYouGoItems, + ], + } + ); + + await db.user.update(user.id, { + stripeCustomerId: user.stripeCustomerId, + stripeProductId: "prod_O9XuIjn7EqYRVW", + stripeCustomerSubscriptionId: subscription.id, + oldProPlan: true, + }); + } + } else { + res.json({ + errors: [ + `Unable to migrate pro user - customer not found for user=${user.id} email=${user.email} subscriptionId=${user.stripeCustomerSubscriptionId}`, + ], + }); + await db.user.update(user.id, { + isActiveSubscription: false, + }); + return; + } + res.json({ + result: "Migrated pro user with email " + user.email + " to hacker plan", + }); +}); + +app.post("/migrate-test-products", async (req, res) => { + if (req.config.stripeSecretKey != req.body.stripeSecretKey) { + res.status(403); + return res.json({ errors: ["unauthorized"] }); + } + + const [currentUser, _newCursor] = await db.user.find( + [sql`users.data->>'stripeCustomerId' IS NOT NULL`], + { + limit: 1, + useReplica: false, + } + ); + + if (currentUser.length < 0) { + res.json({ + errors: ["no users found"], + }); + return; + } + + let user = currentUser[0]; + + // If user.newStripeProductId is in testProducts, migrate it to the new product + if (testProducts.includes(user.newStripeProductId)) { + // get the stripe customer + const customer = await req.stripe.customers.get({ + email: user.email, + }); + + let payAsYouGoItems = []; + let isPayAsYouGoPlan = + products[productMapping[user.newStripeProductId]].payAsYouGo; + + // Get the prices associated with the subscription + const subscriptionItems = await req.stripe.subscriptionItems.list({ + subscription: user.stripeCustomerSubscriptionId, + }); + + // fetch prices associated with new product + const items = await req.stripe.prices.list({ + lookup_keys: products[productMapping[user.newStripeProductId]].lookupKeys, + }); + + if (isPayAsYouGoPlan) { + // Get the prices for the pay as you go product + const payAsYouGoPrices = await req.stripe.prices.list({ + lookup_keys: products["pay_as_you_go_1"].lookupKeys, + }); + + // Map the prices to the additional items array + payAsYouGoItems = payAsYouGoPrices.data.map((item) => ({ + price: item.id, + })); + } + + // Subscribe the user to the new product + const subscription = await req.stripe.subscriptions.update( + user.stripeCustomerSubscriptionId, + { + billing_cycle_anchor: "now", // reset billing anchor when updating subscription + items: [ + ...subscriptionItems.data.map((item) => { + // Check if the item is metered + const isMetered = item.price.recurring.usage_type === "metered"; + return { + id: item.id, + deleted: true, + clear_usage: isMetered ? true : undefined, // If metered, clear usage + price: item.price.id, + }; + }), + ...items.data.map((item) => ({ + price: item.id, + })), + ...payAsYouGoItems, + ], + } + ); + + // Update user's customer, product, subscription, and payment id in our db + await db.user.update(user.id, { + stripeCustomerId: customer.id, + stripeProductId: productMapping[user.newStripeProductId], + stripeCustomerSubscriptionId: subscription.id, + stripeCustomerPaymentMethodId: null, + newStripeProductId: null, + }); + } + + res.json({ + result: "Migrated user with email " + user.email + " to new product", + }); +}); + +export default app; diff --git a/packages/api/src/controllers/usage.js b/packages/api/src/controllers/usage.js deleted file mode 100644 index 80c3fab1c4..0000000000 --- a/packages/api/src/controllers/usage.js +++ /dev/null @@ -1,75 +0,0 @@ -import Router from "express/lib/router"; -import { db } from "../store"; -import { authorizer, validatePost } from "../middleware"; - -const app = Router(); - -app.get("/", authorizer({ anyAdmin: true }), async (req, res) => { - let { fromTime, toTime } = req.query; - - // if time range isn't specified return all usage - if (!fromTime && !toTime) { - fromTime = +new Date(2020, 0); // start at beginning - toTime = +new Date(); - } - - const cachedUsageHistory = await db.stream.cachedUsageHistory( - fromTime, - toTime, - { - useReplica: true, - } - ); - - res.status(200); - res.json(cachedUsageHistory); -}); - -app.post( - "/update", - authorizer({ anyAdmin: true }), - validatePost("usage"), - async (req, res) => { - let { fromTime, toTime } = req.query; - - // if time range isn't specified return all usage - if (!fromTime) { - let rows = ( - await db.usage.find( - {}, - { limit: 1, order: "data->>'date' DESC", useReplica: true } - ) - )[0]; - - if (rows.length) { - fromTime = rows[0].date; // get last updated date from cache - } else { - fromTime = +new Date(2020, 0); // start at beginning - } - } - - if (!toTime) { - toTime = +new Date(); - } - - let usageHistory = await db.stream.usageHistory(fromTime, toTime, { - useReplica: true, - }); - - // store each day of usage - for (const row of usageHistory) { - const dbRow = await req.store.get(`usage/${row.id}`); - // if row already exists in cache, update it, otherwise create it - if (dbRow) { - await req.store.replace({ kind: "usage", ...row }); - } else { - await req.store.create({ kind: "usage", ...row }); - } - } - - res.status(200); - res.json(usageHistory); - } -); - -export default app; diff --git a/packages/api/src/controllers/usage.ts b/packages/api/src/controllers/usage.ts new file mode 100644 index 0000000000..40f59e1807 --- /dev/null +++ b/packages/api/src/controllers/usage.ts @@ -0,0 +1,47 @@ +import Router from "express/lib/router"; +import { db } from "../store"; +import { authorizer, validatePost } from "../middleware"; +import { products } from "../config"; +import { reportUsage } from "./stripe"; + +const app = Router(); + +app.get("/", authorizer({ anyAdmin: true }), async (req, res) => { + let { fromTime, toTime } = req.query; + + // if time range isn't specified return all usage + if (!fromTime && !toTime) { + fromTime = +new Date(2020, 0); // start at beginning + toTime = +new Date(); + } + + const cachedUsageHistory = await db.stream.cachedUsageHistory( + fromTime, + toTime, + { + useReplica: true, + } + ); + + res.status(200); + res.json(cachedUsageHistory); +}); + +app.post( + "/update", + authorizer({ anyAdmin: true }), + validatePost("usage"), + async (req, res) => { + let { fromTime, toTime } = req.query; + + let token = req.token; + + // New automated billing usage report + let result = await reportUsage(req, token); + + res.status(200); + res.json(result); + } +); + +export default app; diff --git a/packages/api/src/controllers/user.ts b/packages/api/src/controllers/user.ts index 6fded4f47a..493f8bf3a5 100644 --- a/packages/api/src/controllers/user.ts +++ b/packages/api/src/controllers/user.ts @@ -121,7 +121,7 @@ export async function terminateUserStreams( type StripeProductIDs = CreateSubscription["stripeProductId"]; -const defaultProductId: StripeProductIDs = "prod_0"; +const defaultProductId: StripeProductIDs = "prod_O9XuIjn7EqYRVW"; async function getOrCreateCustomer(stripe: Stripe, email: string) { const existing = await stripe.customers.list({ email }); @@ -146,6 +146,7 @@ async function createSubscription( const prices = await stripe.prices.list({ lookup_keys: products[stripeProductId].lookupKeys, }); + return await stripe.subscriptions.create({ cancel_at_period_end: false, customer: stripeCustomerId, @@ -770,8 +771,6 @@ app.post( } ); -const deprecatedProducts = ["prod_0", "prod_1", "prod_2"]; - app.post( "/create-subscription", validatePost("create-subscription"), @@ -840,19 +839,6 @@ app.post( .send({ errors: ["could not create subscription"] }); } - if ( - stripeProductId !== "prod_0" && - stripeProductId !== "prod_1" && - stripeProductId !== "prod_2" - ) { - await db.user.update(user.id, { - newStripeProductId: stripeProductId, - stripeCustomerSubscriptionId: subscription.id, - }); - res.send(subscription); - return; - } - // Update user's product and subscription id in our db await db.user.update(user.id, { stripeProductId, @@ -920,7 +906,7 @@ app.post( ); // Temporarily skip updating the subscription if user is selecting a new plan - if ( + /*if ( payload.stripeProductId !== "prod_0" && payload.stripeProductId !== "prod_1" && payload.stripeProductId !== "prod_2" @@ -932,7 +918,7 @@ app.post( }); res.send(subscription); return; - } + }*/ // Get the prices associated with the subscription const subscriptionItems = await req.stripe.subscriptionItems.list({ @@ -975,25 +961,63 @@ app.post( */ - // Update the customer's subscription plan. - // Stripe will automatically invoice the customer based on its usage up until this point - const updatedSubscription = await req.stripe.subscriptions.update( - payload.stripeCustomerSubscriptionId, - { - billing_cycle_anchor: "now", // reset billing anchor when updating subscription - items: [ - ...subscriptionItems.data.map((item) => ({ - id: item.id, - deleted: true, - clear_usage: true, - price: item.price.id, - })), - ...items.data.map((item) => ({ - price: item.id, - })), - ], + let updatedSubscription; + if (products[payload.stripeProductId].deprecated) { + // Update the customer's subscription plan. + // Stripe will automatically invoice the customer based on its usage up until this point + updatedSubscription = await req.stripe.subscriptions.update( + payload.stripeCustomerSubscriptionId, + { + billing_cycle_anchor: "now", // reset billing anchor when updating subscription + items: [ + ...subscriptionItems.data.map((item) => ({ + id: item.id, + deleted: true, + clear_usage: true, + price: item.price.id, + })), + ...items.data.map((item) => ({ + price: item.id, + })), + ], + } + ); + } else { + let payAsYouGoItems = []; + if (products[payload.stripeProductId]?.payAsYouGo) { + // Get the prices for the pay as you go product + const payAsYouGoPrices = await req.stripe.prices.list({ + lookup_keys: products["pay_as_you_go_1"].lookupKeys, + }); + + // Map the prices to the additional items array + payAsYouGoItems = payAsYouGoPrices.data.map((item) => ({ + price: item.id, + })); } - ); + updatedSubscription = await req.stripe.subscriptions.update( + payload.stripeCustomerSubscriptionId, + { + billing_cycle_anchor: "now", // reset billing anchor when updating subscription + items: [ + ...subscriptionItems.data.map((item) => { + // Check if the item is metered + const isMetered = item.price.recurring.usage_type === "metered"; + return { + id: item.id, + deleted: true, + clear_usage: isMetered ? true : undefined, // If metered, clear usage + price: item.price.id, + }; + }), + ...items.data.map((item) => ({ + price: item.id, + })), + ...payAsYouGoItems, + ], + } + ); + } // Update user's product subscription in our db await db.user.update(user.id, { @@ -1038,6 +1062,31 @@ app.post( } ); +app.post( + "/retrieve-upcoming-invoice", + authorizer({ noApiToken: true }), + requireStripe(), + async (req, res) => { + let { stripeCustomerId } = req.body; + if (req.user.stripeCustomerId !== stripeCustomerId) { + return res.status(403).json({ errors: ["access forbidden"] }); + } + + let subscriptionItems = await req.stripe.subscriptionItems.list({ + subscription: req.user.stripeCustomerSubscriptionId, + }); + + const invoices = await req.stripe.invoices.retrieveUpcoming({ + customer: stripeCustomerId, + }); + + res.status(200).json({ + invoices, + subscriptionItems, + }); + } +); + app.post( "/retrieve-payment-method", authorizer({ noApiToken: true }), diff --git a/packages/api/src/schema/db-schema.yaml b/packages/api/src/schema/db-schema.yaml index 1671ad9844..33b0c46ac0 100644 --- a/packages/api/src/schema/db-schema.yaml +++ b/packages/api/src/schema/db-schema.yaml @@ -225,6 +225,10 @@ components: - scale_1 - pay_as_you_go_1 - prod_4 + - prod_O9XuIjn7EqYRVW + - prod_O9XtHhI6rbTT1B + - prod_O9XtcfOSMjSD5L + - prod_O9XuWMU1Up6QKf update-subscription: type: object required: @@ -256,6 +260,10 @@ components: - scale_1 - pay_as_you_go_1 - prod_4 + - prod_O9XuIjn7EqYRVW + - prod_O9XtHhI6rbTT1B + - prod_O9XtcfOSMjSD5L + - prod_O9XuWMU1Up6QKf update-customer-payment-method: type: object required: @@ -983,6 +991,10 @@ components: - scale_1 - pay_as_you_go_1 - prod_4 + - prod_O9XuIjn7EqYRVW + - prod_O9XtHhI6rbTT1B + - prod_O9XtcfOSMjSD5L + - prod_O9XuWMU1Up6QKf newStripeProductId: type: string enum: @@ -991,6 +1003,16 @@ components: - scale_1 - pay_as_you_go_1 - prod_4 + - prod_O9XuIjn7EqYRVW + - prod_O9XtHhI6rbTT1B + - prod_O9XtcfOSMjSD5L + - prod_O9XuWMU1Up6QKf + oldProPlan: + type: boolean + default: false + isActiveSubscription: + type: boolean + default: true stripeCustomerId: type: string example: cus_Jv6KvgT0DCH8HU diff --git a/packages/www/components/PlanForm/index.tsx b/packages/www/components/PlanForm/index.tsx index 5b291f4e08..aed6b868e4 100644 --- a/packages/www/components/PlanForm/index.tsx +++ b/packages/www/components/PlanForm/index.tsx @@ -434,14 +434,14 @@ const PlanForm = ({ You are currently using the{" "} - {products[user.newStripeProductId]?.name || - products[stripeProductId]?.name}{" "} + {products[stripeProductId]?.name || + products[user.newStripeProductId]?.name}{" "} plan. Do you want to{" "} {products[stripeProductId].order < - products[user.newStripeProductId]?.order || - products[stripeProductId]?.order - ? "upgrade" - : "downgrade"}{" "} + products[stripeProductId]?.order || + products[user.newStripeProductId]?.order + ? "downgrade" + : "upgrade"}{" "} to the {products[stripeProductId].name} plan? diff --git a/packages/www/components/Plans/index.tsx b/packages/www/components/Plans/index.tsx index 05010791f1..5957dbcefd 100644 --- a/packages/www/components/Plans/index.tsx +++ b/packages/www/components/Plans/index.tsx @@ -251,24 +251,26 @@ const Plans = ({ textAlign: "center", }}> - {products["hacker_1"].name} + {products["prod_O9XuIjn7EqYRVW"].name} Free { if (!dashboard) { router.push("/register"); @@ -327,26 +329,28 @@ const Plans = ({ textAlign: "center", }}> - {products["growth_1"].name} + {products["prod_O9XtHhI6rbTT1B"].name} $100 per month* { if (dashboard) { setIsTourOpen(false); @@ -487,26 +491,28 @@ const Plans = ({ textAlign: "center", }}> - {products["scale_1"].name} + {products["prod_O9XtcfOSMjSD5L"].name} $500 per month* { if (dashboard) { setIsTourOpen(false); diff --git a/packages/www/components/UpcomingInvoiceTable/index.tsx b/packages/www/components/UpcomingInvoiceTable/index.tsx index 2dd2d7d71a..21103261d3 100644 --- a/packages/www/components/UpcomingInvoiceTable/index.tsx +++ b/packages/www/components/UpcomingInvoiceTable/index.tsx @@ -1,7 +1,16 @@ import { Table, Thead, Tbody, Tr, Th, Td } from "@livepeer/design-system"; -const UpcomingInvoiceTable = ({ subscription, usage, prices }) => { - const transcodingPrice = prices[0].price; +const UpcomingInvoiceTable = ({ + subscription, + usage, + product, + overUsageBill, + upcomingInvoice, +}) => { + const price = product.monthlyPrice; + const transcodingPrice = product.usage[0].price; + const deliveryPrice = product.usage[1].price; + const storagePrice = product.usage[2].price; return ( { - + + + - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/www/components/UsageSummary/index.tsx b/packages/www/components/UsageSummary/index.tsx index 3c7aab081e..821fa28b30 100644 --- a/packages/www/components/UsageSummary/index.tsx +++ b/packages/www/components/UsageSummary/index.tsx @@ -23,6 +23,17 @@ const StyledUpcomingIcon = styled(UpcomingIcon, { color: "$gray", }); +export interface OverUsageBill { + transcodingBill: OverUsageItem; + deliveryBill: OverUsageItem; + storageBill: OverUsageItem; +} + +export interface OverUsageItem { + units: number; + total: number; +} + export const UsageCard = ({ title, usage, limit, loading = false }) => { return ( { const { user, getBillingUsage, + getUpcomingInvoice, getSubscription, getInvoices, getUserProduct, @@ -73,8 +85,13 @@ const UsageSummary = () => { const [usage, setUsage] = useState(null); const [subscription, setSubscription] = useState(null); const [invoices, setInvoices] = useState(null); - const prices = getUserProduct(user).usage; - const transcodingPrice = prices[0].price; + const [overUsageBill, setOverUsageBill] = useState( + null + ); + const [upcomingInvoiceTotal, setUpcomingInvoiceTotal] = useState(0); + const [upcomingInvoice, setUpcomingInvoice] = useState(null); + const product = getUserProduct(user); + const prices = product.usage; useEffect(() => { const doGetInvoices = async (stripeCustomerId) => { @@ -84,13 +101,28 @@ const UsageSummary = () => { } }; - const doGetUsage = async (fromTime, toTime, userId) => { - const [res, usage] = await getBillingUsage( - fromTime * 1000, - toTime * 1000 - ); + const doGetUsage = async (fromTime, toTime, status) => { + fromTime = fromTime * 1000; + toTime = toTime * 1000; + + if (status === "canceled") { + const now = new Date(); + fromTime = new Date(now.getFullYear(), now.getMonth(), 1).getTime(); + toTime = now.getTime(); + } + + let [ + res, + usage = { + TotalUsageMins: 0, + DeliveryUsageMins: 0, + StorageUsageMins: 0, + }, + ] = await getBillingUsage(fromTime, toTime); + if (res.status == 200) { setUsage(usage); + await doCalculateOverUsage(usage); } }; const getSubscriptionAndUsage = async (subscriptionId) => { @@ -101,10 +133,73 @@ const UsageSummary = () => { doGetUsage( subscription?.current_period_start, subscription?.current_period_end, - user.id + subscription?.status ); }; + const doCalculateOverUsage = async (usage) => { + const overusage = await calculateOverUsage(product, usage); + if (overusage) { + const oBill = await calculateOverUsageBill(overusage); + setOverUsageBill(oBill); + let [res, uInvoice] = await getUpcomingInvoice(user.stripeCustomerId); + setUpcomingInvoice(uInvoice?.invoices); + setUpcomingInvoiceTotal((uInvoice?.invoices.total / 100) | 0); + } + }; + + const calculateOverUsage = async (product, usage) => { + const limits = { + transcoding: product?.usage[0].limit, + streaming: product?.usage[1].limit, + storage: product?.usage[2].limit, + }; + + const overUsage = { + TotalUsageMins: Math.max(usage?.TotalUsageMins - limits.transcoding, 0), + DeliveryUsageMins: Math.max( + usage?.DeliveryUsageMins - limits.streaming, + 0 + ), + StorageUsageMins: Math.max(usage?.StorageUsageMins - limits.storage, 0), + }; + + return overUsage; + }; + + const calculateOverUsageBill = async (overusage) => { + const payAsYouGoData = products["prod_O9XuWMU1Up6QKf"]; + + const overUsageBill: OverUsageBill = { + transcodingBill: { + units: overusage.TotalUsageMins, + total: Number( + (overusage.TotalUsageMins * payAsYouGoData.usage[0].price).toFixed( + 2 + ) + ), + }, + deliveryBill: { + units: overusage.DeliveryUsageMins, + total: Number( + ( + overusage.DeliveryUsageMins * payAsYouGoData.usage[1].price + ).toFixed(2) + ), + }, + storageBill: { + units: overusage.StorageUsageMins, + total: Number( + ( + overusage.StorageUsageMins * payAsYouGoData.usage[2].price + ).toFixed(2) + ), + }, + }; + + return overUsageBill; + }; + if (user) { doGetInvoices(user.stripeCustomerId); getSubscriptionAndUsage(user.stripeCustomerSubscriptionId); @@ -149,9 +244,9 @@ const UsageSummary = () => { variant="neutral" css={{ letterSpacing: 0, mt: "7px" }}> {user?.stripeProductId - ? products[user.newStripeProductId]?.name || - products[user.stripeProductId]?.name - : products["prod_0"].name}{" "} + ? products[user.stripeProductId]?.name || + products[user.newStripeProductId]?.name + : products["prod_O9XuIjn7EqYRVW"].name}{" "} Plan @@ -201,16 +296,12 @@ const UsageSummary = () => { + css={{ fontSize: "$3", color: "$hiContrast" }}> Upcoming invoice:{" "} - {usage && - `$${( - (usage.sourceSegmentsDuration / 60) * - transcodingPrice - ).toFixed(2)}`} + {usage && `$${upcomingInvoiceTotal}`} diff --git a/packages/www/hooks/use-api/endpoints/user.ts b/packages/www/hooks/use-api/endpoints/user.ts index 0861a97dbb..399fb1f71e 100644 --- a/packages/www/hooks/use-api/endpoints/user.ts +++ b/packages/www/hooks/use-api/endpoints/user.ts @@ -6,7 +6,12 @@ import { User, } from "@livepeer.studio/api"; import { isDevelopment, shouldStripe } from "../../../lib/utils"; -import { ApiState, UsageData, BillingUsageData } from "../types"; +import { + ApiState, + UsageData, + BillingUsageData, + UpcomingInvoice, +} from "../types"; import { getCursor } from "../helpers"; import { SetStateAction } from "react"; import { storeToken, clearToken } from "../tokenStorage"; @@ -93,7 +98,7 @@ export const register = async ({ // Subscribe customer to free plan upon registation await createSubscription({ stripeCustomerId: customer.id, - stripeProductId: "prod_0", + stripeProductId: "prod_O9XuIjn7EqYRVW", }); return login(email, password); @@ -165,7 +170,7 @@ export const getUser = async ( const customer = await createCustomer(user.email); await createSubscription({ stripeCustomerId: customer.id, - stripeProductId: "prod_0", + stripeProductId: "prod_O9XuIjn7EqYRVW", }); [res, user] = await context.fetch(`/user/${userId}`, opts); } @@ -175,9 +180,12 @@ export const getUser = async ( // Get current Stripe product, allowing for development users that don't have any export const getUserProduct = (user: User) => { if (hasStripe) { - return products[user.stripeProductId]; + return products[user.stripeProductId] || products[user.newStripeProductId]; } - return products[user.stripeProductId || "prod_0"]; + return ( + products[user.stripeProductId] || + products[user.newStripeProductId || "prod_O9XuIjn7EqYRVW"] + ); }; export const getUsers = async ( @@ -223,9 +231,34 @@ export const getBillingUsage = async ( {} ); + if (!usage) { + return [ + res, + { + TotalUsageMins: 0, + DeliveryUsageMins: 0, + StorageUsageMins: 0, + } as BillingUsageData | ApiError, + ]; + } + return [res, usage as BillingUsageData | ApiError]; }; +export const getUpcomingInvoice = async ( + stripeCustomerId: string +): Promise<[Response, any | ApiError]> => { + let [res, invoice] = await context.fetch(`/user/retrieve-upcoming-invoice`, { + method: "POST", + body: JSON.stringify({ stripeCustomerId }), + headers: { + "content-type": "application/json", + }, + }); + + return [res, invoice as any | ApiError]; +}; + export const makeUserAdmin = async ( email, admin diff --git a/packages/www/hooks/use-api/types.ts b/packages/www/hooks/use-api/types.ts index 4b702575e5..2e68389b06 100644 --- a/packages/www/hooks/use-api/types.ts +++ b/packages/www/hooks/use-api/types.ts @@ -58,3 +58,207 @@ export interface Ingest { playback: string; base: string; } + +export interface UpcomingInvoice { + object: string; + account_country: string; + account_name: string; + account_tax_ids: any; + amount_due: number; + amount_paid: number; + amount_remaining: number; + amount_shipping: number; + application: any; + application_fee_amount: any; + attempt_count: number; + attempted: boolean; + automatic_tax: AutomaticTax; + billing_reason: string; + charge: any; + collection_method: string; + created: number; + currency: string; + custom_fields: any; + customer: string; + customer_address: any; + customer_email: string; + customer_name: any; + customer_phone: any; + customer_shipping: any; + customer_tax_exempt: string; + customer_tax_ids: any[]; + default_payment_method: any; + default_source: any; + default_tax_rates: any[]; + description: any; + discount: any; + discounts: any[]; + due_date: any; + effective_at: any; + ending_balance: number; + footer: any; + from_invoice: any; + last_finalization_error: any; + latest_revision: any; + lines: Lines; + livemode: boolean; + metadata: Metadata4; + next_payment_attempt: number; + number: any; + on_behalf_of: any; + paid: boolean; + paid_out_of_band: boolean; + payment_intent: any; + payment_settings: PaymentSettings; + period_end: number; + period_start: number; + post_payment_credit_notes_amount: number; + pre_payment_credit_notes_amount: number; + quote: any; + receipt_number: any; + rendering_options: any; + shipping_cost: any; + shipping_details: any; + starting_balance: number; + statement_descriptor: any; + status: string; + status_transitions: StatusTransitions; + subscription: string; + subtotal: number; + subtotal_excluding_tax: number; + tax: any; + test_clock: any; + total: number; + total_discount_amounts: any[]; + total_excluding_tax: number; + total_tax_amounts: any[]; + transfer_data: any; + webhooks_delivered_at: any; +} + +export interface AutomaticTax { + enabled: boolean; + status: any; +} + +export interface Lines { + object: string; + data: Daum[]; + has_more: boolean; + total_count: number; + url: string; +} + +export interface Daum { + id: string; + object: string; + amount: number; + amount_excluding_tax: number; + currency: string; + description: string; + discount_amounts: any[]; + discountable: boolean; + discounts: any[]; + invoice_item?: string; + livemode: boolean; + metadata: Metadata; + period: Period; + plan: Plan; + price: Price; + proration: boolean; + proration_details: ProrationDetails; + quantity: number; + subscription: string; + subscription_item: string; + tax_amounts: any[]; + tax_rates: any[]; + type: string; + unit_amount_excluding_tax?: string; +} + +export interface Metadata {} + +export interface Period { + end: number; + start: number; +} + +export interface Plan { + id: string; + object: string; + active: boolean; + aggregate_usage?: string; + amount?: number; + amount_decimal: string; + billing_scheme: string; + created: number; + currency: string; + interval: string; + interval_count: number; + livemode: boolean; + metadata: Metadata2; + nickname?: string; + product: string; + tiers_mode: any; + transform_usage: any; + trial_period_days: any; + usage_type: string; +} + +export interface Metadata2 {} + +export interface Price { + id: string; + object: string; + active: boolean; + billing_scheme: string; + created: number; + currency: string; + custom_unit_amount: any; + livemode: boolean; + lookup_key: string; + metadata: Metadata3; + nickname?: string; + product: string; + recurring: Recurring; + tax_behavior: string; + tiers_mode: any; + transform_quantity: any; + type: string; + unit_amount?: number; + unit_amount_decimal: string; +} + +export interface Metadata3 {} + +export interface Recurring { + aggregate_usage?: string; + interval: string; + interval_count: number; + trial_period_days: any; + usage_type: string; +} + +export interface ProrationDetails { + credited_items?: CreditedItems; +} + +export interface CreditedItems { + invoice: string; + invoice_line_items: string[]; +} + +export interface Metadata4 {} + +export interface PaymentSettings { + default_mandate: any; + payment_method_options: any; + payment_method_types: any; +} + +export interface StatusTransitions { + finalized_at: any; + marked_uncollectible_at: any; + paid_at: any; + voided_at: any; +} diff --git a/packages/www/pages/dashboard/billing/index.tsx b/packages/www/pages/dashboard/billing/index.tsx index d7d1436de9..cb2bc57d4d 100644 --- a/packages/www/pages/dashboard/billing/index.tsx +++ b/packages/www/pages/dashboard/billing/index.tsx @@ -20,19 +20,36 @@ import { useQuery, useQueryClient } from "react-query"; import { DashboardBilling as Content } from "content"; import React, { PureComponent } from "react"; +export interface OverUsageBill { + transcodingBill: OverUsageItem; + deliveryBill: OverUsageItem; + storageBill: OverUsageItem; +} + +export interface OverUsageItem { + units: number; + total: number; +} + const Billing = () => { useLoggedIn(); const { user, - getUsage, getBillingUsage, getSubscription, getInvoices, getPaymentMethod, + getUserProduct, + getUpcomingInvoice, } = useApi(); const [usage, setUsage] = useState(null); const [subscription, setSubscription] = useState(null); const [invoices, setInvoices] = useState(null); + const [upcomingInvoiceTotal, setUpcomingInvoiceTotal] = useState(0); + const [upcomingInvoice, setUpcomingInvoice] = useState(null); + const [overUsageBill, setOverUsageBill] = useState( + null + ); const fetcher = useCallback(async () => { if (user?.stripeCustomerPaymentMethodId) { @@ -63,12 +80,88 @@ const Billing = () => { } }; - const doGetUsage = async (fromTime, toTime, userId) => { - const [res, usage] = await getUsage(fromTime, toTime, userId); + const doGetUsage = async (fromTime, toTime, status) => { + fromTime = fromTime * 1000; + toTime = toTime * 1000; + + if (status === "canceled") { + const now = new Date(); + fromTime = new Date(now.getFullYear(), now.getMonth(), 1).getTime(); + toTime = now.getTime(); + } + + let [res, billingUsage] = await getBillingUsage(fromTime, toTime); + if (res.status == 200) { setUsage(usage); + await doCalculateOverUsage(usage); + } + }; + + const doCalculateOverUsage = async (usage) => { + const overusage = await calculateOverUsage(usage); + if (overusage) { + const oBill = await calculateOverUsageBill(overusage); + setOverUsageBill(oBill); + let [res, uInvoice] = await getUpcomingInvoice(user.stripeCustomerId); + setUpcomingInvoice(uInvoice?.invoices); + setUpcomingInvoiceTotal((uInvoice?.invoices.total / 100) | 0); } }; + + const calculateOverUsage = async (usage) => { + const product = getUserProduct(user); + const limits = { + transcoding: product?.usage[0].limit, + streaming: product?.usage[1].limit, + storage: product?.usage[2].limit, + }; + + const overUsage = { + TotalUsageMins: Math.max(usage?.TotalUsageMins - limits.transcoding, 0), + DeliveryUsageMins: Math.max( + usage?.DeliveryUsageMins - limits.streaming, + 0 + ), + StorageUsageMins: Math.max(usage?.StorageUsageMins - limits.storage, 0), + }; + + return overUsage; + }; + + const calculateOverUsageBill = async (overusage) => { + const payAsYouGoData = products["prod_O9XuWMU1Up6QKf"]; + + const overUsageBill: OverUsageBill = { + transcodingBill: { + units: overusage.TotalUsageMins, + total: Number( + (overusage.TotalUsageMins * payAsYouGoData.usage[0].price).toFixed( + 2 + ) + ), + }, + deliveryBill: { + units: overusage.DeliveryUsageMins, + total: Number( + ( + overusage.DeliveryUsageMins * payAsYouGoData.usage[1].price + ).toFixed(2) + ), + }, + storageBill: { + units: overusage.StorageUsageMins, + total: Number( + ( + overusage.StorageUsageMins * payAsYouGoData.usage[2].price + ).toFixed(2) + ), + }, + }; + + return overUsageBill; + }; + const getSubscriptionAndUsage = async (subscriptionId) => { const [res, subscription] = await getSubscription(subscriptionId); if (res.status == 200) { @@ -77,7 +170,7 @@ const Billing = () => { doGetUsage( subscription?.current_period_start, subscription?.current_period_end, - user.id + subscription?.status ); }; @@ -177,9 +270,9 @@ const Billing = () => { variant="neutral" css={{ mx: "$1", fontWeight: 700, letterSpacing: 0 }}> {user?.stripeProductId - ? products[user.newStripeProductId]?.name || - products[user.stripeProductId]?.name - : products["prod_0"]?.name} + ? products[user.stripeProductId]?.name || + products[user.newStripeProductId]?.name + : products["prod_O9XuIjn7EqYRVW"]?.name} plan. @@ -296,7 +389,9 @@ const Billing = () => { ) )} diff --git a/packages/www/pages/dashboard/billing/plans.tsx b/packages/www/pages/dashboard/billing/plans.tsx index cd67afe0c4..63c3b58ce7 100644 --- a/packages/www/pages/dashboard/billing/plans.tsx +++ b/packages/www/pages/dashboard/billing/plans.tsx @@ -48,10 +48,10 @@ const PlansPage = () => { diff --git a/packages/www/pages/dashboard/usage/index.tsx b/packages/www/pages/dashboard/usage/index.tsx index d0d3ff9de7..639f55de0a 100644 --- a/packages/www/pages/dashboard/usage/index.tsx +++ b/packages/www/pages/dashboard/usage/index.tsx @@ -38,7 +38,11 @@ const Usage = () => { getUserProduct, } = useApi(); const [_usage, setUsage] = useState(null); - const [billingUsage, setBillingUsage] = useState(null); + const [billingUsage, setBillingUsage] = useState({ + TotalUsageMins: 0, + DeliveryUsageMins: 0, + StorageUsageMins: 0, + }); const [subscription, setSubscription] = useState(null); const [timestep, setTimestep] = useState("day"); const [from, setFrom] = useState(0); @@ -47,7 +51,14 @@ const Usage = () => { const doSetTimeStep = async (ts: string) => { setTimestep(ts); - const [res, usage] = await getBillingUsage(from, to, null, ts); + const [ + res, + usage = { + TotalUsageMins: 0, + DeliveryUsageMins: 0, + StorageUsageMins: 0, + }, + ] = await getBillingUsage(from, to, null, ts); if (res.status == 200 && Array.isArray(usage)) { const now = new Date(); const currentMonth = now.toLocaleString("default", { month: "short" }); @@ -84,25 +95,37 @@ const Usage = () => { } }; - const doGetBillingUsage = async (fromTime, toTime) => { - // Gather current month data - /*const now = new Date(); - const fromTime = new Date(now.getFullYear(), now.getMonth(), 1).getTime(); - const toTime = now.getTime(); + const doGetBillingUsage = async ( + fromTime: any, + toTime: any, + status: any + ) => { + fromTime = fromTime * 1000; + toTime = toTime * 1000; - doSetFrom(fromTime); - doSetTo(toTime);*/ + // if subscription is cancelled, get current month data + if (status === "canceled") { + const now = new Date(); + fromTime = new Date(now.getFullYear(), now.getMonth(), 1).getTime(); + toTime = now.getTime(); + } + + let [res, usage] = await getBillingUsage(fromTime, toTime); - const [res, usage] = await getBillingUsage( - fromTime * 1000, - toTime * 1000 - ); if (res.status == 200) { + if (!usage) { + usage = { + TotalUsageMins: 0, + DeliveryUsageMins: 0, + StorageUsageMins: 0, + }; + } setBillingUsage(usage); } + const [res2, usageByDay] = await getBillingUsage( - fromTime * 1000, - toTime * 1000, + fromTime, + toTime, null, timestep ); @@ -123,7 +146,8 @@ const Usage = () => { ); doGetBillingUsage( subscription?.current_period_start, - subscription?.current_period_end + subscription?.current_period_end, + subscription?.status ); }; @@ -261,14 +285,14 @@ const Usage = () => { mr: "$3", fontWeight: 600, letterSpacing: "0", - display: "none", + display: "", }}> Charts - + - + @@ -288,7 +312,7 @@ const Usage = () => { - + @@ -299,7 +323,7 @@ const Usage = () => { - + diff --git a/packages/www/public/sitemap-0.xml b/packages/www/public/sitemap-0.xml index 0870ef99f3..91c9097e16 100644 --- a/packages/www/public/sitemap-0.xml +++ b/packages/www/public/sitemap-0.xml @@ -1,27 +1,20 @@ -https://livepeer.studiodaily0.72023-07-06T17:35:55.122Z -https://livepeer.studio/contactdaily0.72023-07-06T17:35:55.123Z -https://livepeer.studio/dashboarddaily0.72023-07-06T17:35:55.123Z -https://livepeer.studio/dashboard/assetsdaily0.72023-07-06T17:35:55.123Z -https://livepeer.studio/dashboard/billingdaily0.72023-07-06T17:35:55.123Z -https://livepeer.studio/dashboard/billing/plansdaily0.72023-07-06T17:35:55.123Z -https://livepeer.studio/dashboard/developers/api-keysdaily0.72023-07-06T17:35:55.123Z -https://livepeer.studio/dashboard/developers/signing-keysdaily0.72023-07-06T17:35:55.123Z -https://livepeer.studio/dashboard/developers/webhooksdaily0.72023-07-06T17:35:55.123Z -https://livepeer.studio/dashboard/sessionsdaily0.72023-07-06T17:35:55.123Z -https://livepeer.studio/dashboard/stream-healthdaily0.72023-07-06T17:35:55.123Z -https://livepeer.studio/dashboard/streamsdaily0.72023-07-06T17:35:55.123Z -https://livepeer.studio/dashboard/usagedaily0.72023-07-06T17:35:55.123Z -https://livepeer.studio/forgot-passworddaily0.72023-07-06T17:35:55.123Z -https://livepeer.studio/registerdaily0.72023-07-06T17:35:55.123Z -https://livepeer.studio/reset-passworddaily0.72023-07-06T17:35:55.123Z -https://livepeer.studio/verifydaily0.72023-07-06T17:35:55.123Z -https://livepeer.studio/privacy-policydaily0.72023-07-06T17:35:55.123Z -https://livepeer.studio/decentralized-storage-arweavedaily0.72023-07-06T17:35:55.123Z -https://livepeer.studio/terms-of-servicedaily0.72023-07-06T17:35:55.123Z -https://livepeer.studio/pricing-faqdaily0.72023-07-06T17:35:55.123Z -https://livepeer.studio/decentralized-storage-ipfsdaily0.72023-07-06T17:35:55.123Z -https://livepeer.studio/dstoragedaily0.72023-07-06T17:35:55.123Z -https://livepeer.studio/bundlrdaily0.72023-07-06T17:35:55.123Z +https://livepeer.studiodaily0.72023-07-07T16:37:14.836Z +https://livepeer.studio/contactdaily0.72023-07-07T16:37:14.836Z +https://livepeer.studio/dashboarddaily0.72023-07-07T16:37:14.836Z +https://livepeer.studio/dashboard/assetsdaily0.72023-07-07T16:37:14.836Z +https://livepeer.studio/dashboard/billingdaily0.72023-07-07T16:37:14.836Z +https://livepeer.studio/dashboard/billing/plansdaily0.72023-07-07T16:37:14.836Z +https://livepeer.studio/dashboard/developers/api-keysdaily0.72023-07-07T16:37:14.836Z +https://livepeer.studio/dashboard/developers/signing-keysdaily0.72023-07-07T16:37:14.836Z +https://livepeer.studio/dashboard/developers/webhooksdaily0.72023-07-07T16:37:14.836Z +https://livepeer.studio/dashboard/sessionsdaily0.72023-07-07T16:37:14.836Z +https://livepeer.studio/dashboard/stream-healthdaily0.72023-07-07T16:37:14.836Z +https://livepeer.studio/dashboard/streamsdaily0.72023-07-07T16:37:14.836Z +https://livepeer.studio/dashboard/usagedaily0.72023-07-07T16:37:14.836Z +https://livepeer.studio/forgot-passworddaily0.72023-07-07T16:37:14.836Z +https://livepeer.studio/registerdaily0.72023-07-07T16:37:14.836Z +https://livepeer.studio/reset-passworddaily0.72023-07-07T16:37:14.836Z +https://livepeer.studio/verifydaily0.72023-07-07T16:37:14.836Z \ No newline at end of file
ItemUsage Unit Price Amount Due
Transcoding - {usage && (usage.sourceSegmentsDuration / 60).toFixed(2)} minutes - ${transcodingPrice} / min{product.name} Plan${price} / month{usage && `$${price}`}
OverageUsagePlan Usage Limit {usage && `$${( - (usage.sourceSegmentsDuration / 60) * - transcodingPrice + overUsageBill.transcodingBill.total + + overUsageBill.deliveryBill.total + + overUsageBill.storageBill.total ).toFixed(2)}`}
Transcoding + {usage && parseInt(usage.TotalUsageMins).toLocaleString()} minutes + {product.usage[0].limit.toLocaleString()} minutes${product.usage[0].price} / min + {overUsageBill && + `$${overUsageBill.transcodingBill.total.toFixed(2)}`} +
Delivery + {usage && parseInt(usage.DeliveryUsageMins).toLocaleString()}{" "} + minutes + {product.usage[1].limit.toLocaleString()} minutes${product.usage[1].price} / min + {overUsageBill && `$${overUsageBill.deliveryBill.total.toFixed(2)}`} +
Storage + {usage && parseInt(usage.StorageUsageMins).toLocaleString()} minutes + {product.usage[2].limit.toLocaleString()} minutes${product.usage[2].price} / min + {overUsageBill && `$${overUsageBill.storageBill.total.toFixed(2)}`} +
{ })} {usage && `$${( - (usage.sourceSegmentsDuration / 60) * - transcodingPrice + price + + overUsageBill.transcodingBill.total + + overUsageBill.deliveryBill.total + + overUsageBill.storageBill.total ).toFixed(2)}`}