710 lines
21 KiB
TypeScript
710 lines
21 KiB
TypeScript
import Stripe from "stripe";
|
|
import { loadWebConfig } from "@toolctrl/config";
|
|
import { SubscriptionUsageBasedPriceDto } from "~/application/dtos/subscriptions/SubscriptionUsageBasedPriceDto";
|
|
import { SubscriptionBillingPeriod } from "~/application/enums/subscriptions/SubscriptionBillingPeriod";
|
|
import { cachified } from "./cache.server";
|
|
import { getAllSubscriptionProducts } from "./db/subscriptionProducts.db.server";
|
|
import { convertToCurrency } from "./services/.server/subscriptionService";
|
|
import { getAbsoluteCurrentUrl } from "./url.server";
|
|
import { createScopedLogger } from "./logger.server";
|
|
|
|
const stripeLogger = createScopedLogger("stripe");
|
|
|
|
const STRIPE_API_VERSION = "2025-10-29.clover";
|
|
|
|
let stripeClient: Stripe | null = null;
|
|
let stripeClientSecretKey: string | undefined;
|
|
|
|
export function getStripeSecretKey() {
|
|
return loadWebConfig().config.stripe.secretKey;
|
|
}
|
|
|
|
export function getStripeWebhookSigningSecret() {
|
|
return loadWebConfig().config.stripe.webhookSigningSecret;
|
|
}
|
|
|
|
export function isStripeConfigured() {
|
|
return Boolean(getStripeSecretKey());
|
|
}
|
|
|
|
export function isStripeTestMode() {
|
|
return getStripeSecretKey()?.startsWith("sk_test_") ?? true;
|
|
}
|
|
|
|
export function getStripeClient() {
|
|
const secretKey = getStripeSecretKey();
|
|
if (!secretKey) {
|
|
throw new Error("STRIPE_SK is required before using Stripe services.");
|
|
}
|
|
if (!stripeClient || stripeClientSecretKey !== secretKey) {
|
|
stripeClient = new Stripe(secretKey, {
|
|
apiVersion: STRIPE_API_VERSION,
|
|
});
|
|
stripeClientSecretKey = secretKey;
|
|
}
|
|
return stripeClient;
|
|
}
|
|
|
|
const stripe = new Proxy({} as Stripe, {
|
|
get(_target, property, receiver) {
|
|
return Reflect.get(getStripeClient(), property, receiver);
|
|
},
|
|
});
|
|
|
|
export async function createStripeCheckoutSession(data: {
|
|
subscriptionProduct: {
|
|
billingAddressCollection?: string | null;
|
|
};
|
|
customer?: string;
|
|
line_items: { price: string; quantity?: number }[];
|
|
mode: "payment" | "setup" | "subscription";
|
|
success_url: string;
|
|
cancel_url: string;
|
|
freeTrialDays?: number;
|
|
coupon?: string;
|
|
promo_code?: string;
|
|
referral?: string | null;
|
|
}) {
|
|
let discounts: { coupon?: string; promotion_code?: string }[] | undefined = undefined;
|
|
if (data.coupon) {
|
|
discounts = [{ coupon: data.coupon }];
|
|
} else if (data.promo_code) {
|
|
discounts = [{ promotion_code: data.promo_code }];
|
|
}
|
|
let billing_address_collection: "auto" | "required" | undefined = undefined;
|
|
if (data.subscriptionProduct.billingAddressCollection === "required") {
|
|
billing_address_collection = "required";
|
|
} else if (data.subscriptionProduct.billingAddressCollection === "auto") {
|
|
billing_address_collection = "auto";
|
|
}
|
|
let client_reference_id: string | undefined = undefined;
|
|
if (data.referral) {
|
|
client_reference_id = data.referral;
|
|
}
|
|
return await stripe.checkout.sessions
|
|
.create({
|
|
discounts,
|
|
customer: data.customer,
|
|
line_items: data.line_items,
|
|
mode: data.mode,
|
|
success_url: data.success_url,
|
|
cancel_url: data.cancel_url,
|
|
customer_creation: data.mode === "payment" && !data.customer ? "always" : undefined,
|
|
payment_method_collection: data.mode === "subscription" ? "if_required" : undefined,
|
|
subscription_data: {
|
|
trial_period_days: data.freeTrialDays,
|
|
},
|
|
billing_address_collection,
|
|
client_reference_id,
|
|
allow_promotion_codes: !discounts ? true : undefined,
|
|
})
|
|
.catch((e) => {
|
|
stripeLogger.error({ err: e }, "Stripe checkout session creation failed");
|
|
throw e;
|
|
});
|
|
}
|
|
|
|
export async function createStripeSetupSession(request: Request, customer: string) {
|
|
const currentUrl = getAbsoluteCurrentUrl(request);
|
|
return await stripe.checkout.sessions.create({
|
|
customer,
|
|
success_url: `${currentUrl}?session_id={CHECKOUT_SESSION_ID}`,
|
|
cancel_url: currentUrl,
|
|
mode: "setup",
|
|
payment_method_types: ["card"],
|
|
});
|
|
}
|
|
|
|
export async function deleteStripePaymentMethod(id: string, customerId?: string | null) {
|
|
if (customerId) {
|
|
const paymentMethod = await stripe.paymentMethods.retrieve(id);
|
|
const paymentMethodCustomerId = typeof paymentMethod.customer === "string" ? paymentMethod.customer : paymentMethod.customer?.id;
|
|
if (paymentMethodCustomerId !== customerId) {
|
|
throw new Error("Payment method not found");
|
|
}
|
|
}
|
|
return await stripe.paymentMethods.detach(id);
|
|
}
|
|
|
|
export async function getStripeSession(id: string) {
|
|
return await stripe.checkout.sessions
|
|
.retrieve(id, {
|
|
expand: ["line_items"],
|
|
})
|
|
.catch(() => {
|
|
return null;
|
|
});
|
|
}
|
|
|
|
export async function cancelStripeSubscription(subscriptionId: string) {
|
|
return await stripe.subscriptions.cancel(subscriptionId).catch((e: any) => {
|
|
stripeLogger.warn({ err: e }, "Could not cancel stripe subscription");
|
|
});
|
|
}
|
|
|
|
export async function reactivateStripeSubscription(subscriptionId: string) {
|
|
return await stripe.subscriptions
|
|
.update(subscriptionId, {
|
|
cancel_at_period_end: false,
|
|
})
|
|
.catch((e: any) => {
|
|
stripeLogger.warn({ err: e }, "Could not reactivate stripe subscription");
|
|
});
|
|
}
|
|
|
|
export async function getStripeSubscription(id: string) {
|
|
try {
|
|
return await stripe.subscriptions.retrieve(id);
|
|
} catch (e) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export async function createStripeSubscription(customer: string, price: string, trial_end?: number) {
|
|
return await stripe.subscriptions.create({
|
|
customer,
|
|
items: [
|
|
{
|
|
price,
|
|
},
|
|
],
|
|
trial_end,
|
|
});
|
|
}
|
|
|
|
export const DEFAULT_STRIPE_INVOICE_LIST_LIMIT = 10;
|
|
|
|
export async function getStripeInvoices(id: string | null, options: { limit?: number } = {}) {
|
|
if (!id) {
|
|
return [];
|
|
}
|
|
const limit = Math.min(Math.max(Math.floor(options.limit ?? DEFAULT_STRIPE_INVOICE_LIST_LIMIT), 1), 100);
|
|
try {
|
|
return (
|
|
await stripe.invoices.list({
|
|
customer: id,
|
|
limit,
|
|
})
|
|
).data;
|
|
} catch (e) {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
export async function getStripePaymentIntents(id: string | null, status?: Stripe.PaymentIntent.Status) {
|
|
if (!id) {
|
|
return [];
|
|
}
|
|
try {
|
|
let items = (
|
|
await stripe.paymentIntents.list({
|
|
customer: id,
|
|
})
|
|
).data;
|
|
if (status) {
|
|
items = items.filter((item) => item.status === status);
|
|
}
|
|
return items;
|
|
} catch (e) {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
export async function getStripePaymentIntent(id: string) {
|
|
try {
|
|
return await stripe.paymentIntents.retrieve(id);
|
|
} catch (e) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export async function getStripeUpcomingInvoice(id: string | null) {
|
|
if (!id) {
|
|
return null;
|
|
}
|
|
try {
|
|
const items = await stripe.invoices.createPreview({
|
|
customer: id,
|
|
});
|
|
stripeLogger.debug({ items }, "Stripe upcoming invoice retrieved");
|
|
return items;
|
|
} catch (e) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export async function getStripeUpcomingInvoices(ids: string[]): Promise<Stripe.Invoice[]> {
|
|
try {
|
|
const items = await Promise.all(
|
|
ids.map(async (id) => {
|
|
const items = await stripe.invoices
|
|
.createPreview({
|
|
subscription: id,
|
|
})
|
|
.catch((e) => {
|
|
stripeLogger.error({ err: e }, "Could not get upcoming invoice");
|
|
return null;
|
|
});
|
|
return items;
|
|
})
|
|
);
|
|
return items.filter((x) => x !== null) as Stripe.Invoice[];
|
|
} catch (e) {
|
|
stripeLogger.error({ err: e }, "Failed to fetch upcoming invoices batch");
|
|
return [];
|
|
}
|
|
}
|
|
|
|
export async function getStripePaymentMethods(customer: string | null) {
|
|
if (!customer) {
|
|
return [];
|
|
}
|
|
try {
|
|
return (
|
|
await stripe.paymentMethods.list({
|
|
customer,
|
|
type: "card",
|
|
})
|
|
).data;
|
|
} catch (e) {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
export async function createStripeCustomer(email: string, name: string) {
|
|
return await stripe.customers
|
|
.create({
|
|
email,
|
|
name,
|
|
})
|
|
.catch((e: any) => {
|
|
stripeLogger.error({ err: e }, "createStripeCustomer failed");
|
|
return null;
|
|
});
|
|
}
|
|
|
|
export async function deleteStripeCustomer(id: string) {
|
|
return await stripe.customers.del(id).catch((e: any) => {
|
|
stripeLogger.warn({ err: e }, "Could not delete stripe customer");
|
|
});
|
|
}
|
|
|
|
export async function updateStripeCustomerPaymentMethod(id: string, default_payment_method: string) {
|
|
return await stripe.customers.update(id, {
|
|
invoice_settings: { default_payment_method },
|
|
});
|
|
}
|
|
|
|
export async function createStripeProduct(data: { title: string }) {
|
|
return await stripe.products.create({
|
|
name: data.title,
|
|
});
|
|
}
|
|
|
|
export async function updateStripeProduct(id: string, data: { title: string }) {
|
|
return await stripe.products
|
|
.update(id, {
|
|
name: data.title,
|
|
})
|
|
.catch((error) => {
|
|
// console.error(error);
|
|
// ignore
|
|
});
|
|
}
|
|
|
|
export async function createStripePrice(
|
|
productId: string,
|
|
data: { billingPeriod: SubscriptionBillingPeriod; price: number; currency: string; trialDays?: number }
|
|
) {
|
|
if (!productId) {
|
|
return undefined;
|
|
}
|
|
let recurring:
|
|
| {
|
|
interval: "day" | "week" | "month" | "year";
|
|
trial_period_days: number | undefined;
|
|
interval_count?: number;
|
|
}
|
|
| undefined = undefined;
|
|
switch (data.billingPeriod) {
|
|
case SubscriptionBillingPeriod.MONTHLY:
|
|
recurring = { interval: "month", trial_period_days: data.trialDays };
|
|
break;
|
|
case SubscriptionBillingPeriod.QUARTERLY:
|
|
recurring = { interval: "month", interval_count: 3, trial_period_days: data.trialDays };
|
|
break;
|
|
case SubscriptionBillingPeriod.SEMI_ANNUAL:
|
|
recurring = { interval: "month", interval_count: 6, trial_period_days: data.trialDays };
|
|
break;
|
|
case SubscriptionBillingPeriod.WEEKLY:
|
|
recurring = { interval: "week", trial_period_days: data.trialDays };
|
|
break;
|
|
case SubscriptionBillingPeriod.YEARLY:
|
|
recurring = { interval: "year", trial_period_days: data.trialDays };
|
|
break;
|
|
case SubscriptionBillingPeriod.DAILY:
|
|
recurring = { interval: "day", trial_period_days: data.trialDays };
|
|
break;
|
|
}
|
|
return await stripe.prices.create({
|
|
unit_amount: Math.round(data.price * 100),
|
|
currency: data.currency,
|
|
recurring,
|
|
product: productId,
|
|
active: true,
|
|
});
|
|
}
|
|
|
|
export async function createStripeUsageBasedPrice(productId: string, data: SubscriptionUsageBasedPriceDto) {
|
|
if (!productId) {
|
|
return undefined;
|
|
}
|
|
let interval: "day" | "week" | "month" | "year" = "month";
|
|
let interval_count: number | undefined = undefined;
|
|
switch (data.billingPeriod) {
|
|
case SubscriptionBillingPeriod.MONTHLY:
|
|
interval = "month";
|
|
break;
|
|
case SubscriptionBillingPeriod.QUARTERLY:
|
|
interval = "month";
|
|
interval_count = 3;
|
|
break;
|
|
case SubscriptionBillingPeriod.SEMI_ANNUAL:
|
|
interval = "month";
|
|
interval_count = 6;
|
|
break;
|
|
case SubscriptionBillingPeriod.WEEKLY:
|
|
interval = "week";
|
|
break;
|
|
case SubscriptionBillingPeriod.YEARLY:
|
|
interval = "year";
|
|
break;
|
|
case SubscriptionBillingPeriod.DAILY:
|
|
interval = "day";
|
|
break;
|
|
}
|
|
const usage_type: "licensed" | "metered" = data.usageType === "licensed" ? "licensed" : "metered";
|
|
const tiers_mode: "graduated" | "volume" = data.tiersMode === "graduated" ? "graduated" : "volume";
|
|
const billing_scheme: "per_unit" | "tiered" = data.billingScheme === "per_unit" ? "per_unit" : "tiered";
|
|
const tiers: {
|
|
up_to: "inf" | number;
|
|
unit_amount_decimal?: string;
|
|
flat_amount_decimal?: string;
|
|
}[] = data.tiers
|
|
.sort((x, y) => {
|
|
if (x.to && y.to) {
|
|
return x.to > y.to ? 1 : -1;
|
|
}
|
|
return 1;
|
|
})
|
|
.map((tier) => {
|
|
let up_to: "inf" | number = Number(tier.to);
|
|
if (tier.to === undefined || tier.to === null) {
|
|
up_to = "inf";
|
|
}
|
|
return {
|
|
up_to,
|
|
unit_amount_decimal: tier.perUnitPrice !== undefined ? (Number(tier.perUnitPrice) * 100)?.toString() : undefined,
|
|
flat_amount_decimal: tier.flatFeePrice !== undefined ? (Number(tier.flatFeePrice) * 100)?.toString() : undefined,
|
|
};
|
|
});
|
|
|
|
return await stripe.prices.create({
|
|
currency: data.currency,
|
|
tiers_mode,
|
|
billing_scheme,
|
|
nickname: data.unitTitle,
|
|
recurring: { interval, interval_count, usage_type },
|
|
product: productId,
|
|
tiers,
|
|
active: true,
|
|
expand: ["tiers"],
|
|
});
|
|
}
|
|
|
|
export async function deleteStripeProduct(productId: string) {
|
|
return await stripe.products.del(productId);
|
|
}
|
|
|
|
export async function archiveStripeProduct(productId: string) {
|
|
return await stripe.products
|
|
.update(productId, {
|
|
active: false,
|
|
})
|
|
.catch(() => {
|
|
// ignore
|
|
});
|
|
}
|
|
|
|
export async function archiveStripePrice(productId: string) {
|
|
return await stripe.prices
|
|
.update(productId, {
|
|
active: false,
|
|
})
|
|
.catch(() => {
|
|
// ignore
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Meters-API counterpart of {@link createUsageRecord}. Records a usage
|
|
* event against an operator-provisioned billing meter. The `eventName`
|
|
* matches the Stripe Meter's `event_name`; the customer is identified
|
|
* via the meter's `customer_mapping.event_payload_key` (default
|
|
* `stripe_customer_id`). The `value` payload field corresponds to the
|
|
* meter's `value_settings.event_payload_key` (default `value`).
|
|
*
|
|
* Idempotency: pass `identifier` for client-side de-dup within Stripe's
|
|
* 24h rolling window.
|
|
*
|
|
* Returns `null` on Stripe error so callers can no-op gracefully; check
|
|
* structured logs for failure detail.
|
|
*/
|
|
export async function createMeterEvent(input: {
|
|
eventName: string;
|
|
stripeCustomerId: string;
|
|
value: number;
|
|
identifier?: string;
|
|
timestamp?: number;
|
|
}): Promise<Stripe.Billing.MeterEvent | null> {
|
|
return await stripe.billing.meterEvents
|
|
.create({
|
|
event_name: input.eventName,
|
|
payload: {
|
|
stripe_customer_id: input.stripeCustomerId,
|
|
value: String(input.value),
|
|
},
|
|
identifier: input.identifier,
|
|
timestamp: input.timestamp,
|
|
})
|
|
.catch((e: unknown) => {
|
|
stripeLogger.error({ err: e, eventName: input.eventName }, "Stripe meterEvents.create failed");
|
|
return null;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Meters-API counterpart of {@link getUsageRecordSummaries}. Reads the
|
|
* aggregated event summaries for a given meter id and customer. Returns
|
|
* an empty array on any error so admin UIs degrade gracefully.
|
|
*/
|
|
export async function listMeterEventSummaries(meterId: string, params: {
|
|
customer: string;
|
|
startTime: number;
|
|
endTime: number;
|
|
}): Promise<Stripe.Billing.MeterEventSummary[]> {
|
|
try {
|
|
const result = await stripe.billing.meters.listEventSummaries(meterId, {
|
|
customer: params.customer,
|
|
start_time: params.startTime,
|
|
end_time: params.endTime,
|
|
});
|
|
return result.data;
|
|
} catch (e) {
|
|
stripeLogger.warn({ err: e, meterId }, "listMeterEventSummaries failed; returning empty");
|
|
return [];
|
|
}
|
|
}
|
|
|
|
export async function getStripeCoupon(id: string) {
|
|
return await stripe.coupons.retrieve(id, {
|
|
expand: ["applies_to"],
|
|
});
|
|
}
|
|
|
|
export async function getStripePromotionCode(id: string) {
|
|
return await stripe.promotionCodes.retrieve(id);
|
|
}
|
|
|
|
export async function getStripeCustomer(id: string | null) {
|
|
if (!id) {
|
|
return null;
|
|
}
|
|
return await stripe.customers.retrieve(id, {
|
|
expand: ["invoice_settings"],
|
|
});
|
|
}
|
|
|
|
export async function getOpenInvoices(id: string) {
|
|
return await cachified({
|
|
key: `stripe:openInvoices:${id}`,
|
|
ttl: 1000 * 60 * 60 * 24, // 24 hours
|
|
getFreshValue: () =>
|
|
stripe.invoices.list({
|
|
customer: id,
|
|
status: "open",
|
|
}),
|
|
});
|
|
}
|
|
|
|
export async function createCustomerPortalSession(request: Request, id: string) {
|
|
return await stripe.billingPortal.sessions.create({
|
|
customer: id,
|
|
return_url: getAbsoluteCurrentUrl(request),
|
|
});
|
|
}
|
|
|
|
export async function getAllStripePayments() {
|
|
return cachified({
|
|
key: `stripe:payments`,
|
|
ttl: 1000 * 60 * 60 * 24, // 24 hours
|
|
getFreshValue: async () => {
|
|
let items: Stripe.PaymentIntent[] = [];
|
|
let hasMore = true;
|
|
let startingAfter: string | undefined = undefined;
|
|
let max = 10;
|
|
while (hasMore) {
|
|
const result: Stripe.ApiList<Stripe.PaymentIntent> = await stripe.paymentIntents.list({
|
|
limit: 100,
|
|
starting_after: startingAfter,
|
|
expand: ["data.charges"],
|
|
});
|
|
items = items.concat(result.data);
|
|
hasMore = result.has_more;
|
|
startingAfter = result.data[result.data.length - 1].id;
|
|
max--;
|
|
if (max <= 0) {
|
|
break;
|
|
}
|
|
}
|
|
return items;
|
|
},
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Build a `paymentIntentId → Invoice` map by paginating invoices and
|
|
* pivoting on `Invoice.payment_intent`. Stripe 19 removed
|
|
* `PaymentIntent.invoice`, so consumers that previously read
|
|
* `payment.invoice` directly must traverse the opposite direction
|
|
* (invoice → payment_intent) and look up the invoice via this map.
|
|
*
|
|
* Cached on the same 24h horizon as `getAllStripePayments` so the
|
|
* revenue rollup remains a single-pass aggregation.
|
|
*/
|
|
export async function getAllStripeInvoicesByPaymentIntent(): Promise<Map<string, Stripe.Invoice>> {
|
|
return cachified({
|
|
key: `stripe:invoicesByPaymentIntent`,
|
|
ttl: 1000 * 60 * 60 * 24, // 24 hours
|
|
getFreshValue: async () => {
|
|
const map = new Map<string, Stripe.Invoice>();
|
|
let hasMore = true;
|
|
let startingAfter: string | undefined = undefined;
|
|
let max = 10;
|
|
while (hasMore) {
|
|
const result: Stripe.ApiList<Stripe.Invoice> = await stripe.invoices.list({
|
|
limit: 100,
|
|
starting_after: startingAfter,
|
|
});
|
|
for (const invoice of result.data) {
|
|
// Stripe 19 removed Invoice.payment_intent. The id now lives on
|
|
// each Invoice.payments[].payment.payment_intent (one invoice
|
|
// can have multiple payment attempts; we index by all).
|
|
for (const invoicePayment of invoice.payments?.data ?? []) {
|
|
const ref = invoicePayment.payment.payment_intent;
|
|
const piId = typeof ref === "string" ? ref : ref?.id;
|
|
if (piId) {
|
|
map.set(piId, invoice);
|
|
}
|
|
}
|
|
}
|
|
hasMore = result.has_more;
|
|
startingAfter = result.data[result.data.length - 1]?.id;
|
|
max--;
|
|
if (max <= 0) {
|
|
break;
|
|
}
|
|
}
|
|
return map;
|
|
},
|
|
});
|
|
}
|
|
|
|
export type StripeRevenueByProductPriceCurrency = {
|
|
product: string;
|
|
billingPeriod: SubscriptionBillingPeriod;
|
|
currency: string;
|
|
revenueInCurrency: number;
|
|
count: number;
|
|
countPercentage: number;
|
|
revenueUsd: number;
|
|
revenuePercentageUsd: number;
|
|
};
|
|
export async function getStripeRevenueByProductPriceCurrency(): Promise<StripeRevenueByProductPriceCurrency[]> {
|
|
const items: StripeRevenueByProductPriceCurrency[] = [];
|
|
const allProducts = await getAllSubscriptionProducts();
|
|
|
|
const [payments, invoicesByPaymentIntent] = await Promise.all([
|
|
getAllStripePayments(),
|
|
getAllStripeInvoicesByPaymentIntent(),
|
|
]);
|
|
payments.forEach((payment) => {
|
|
if (payment.status !== "succeeded") {
|
|
return;
|
|
}
|
|
// Stripe 19 removed PaymentIntent.invoice. Look up the invoice via
|
|
// the inverted map built from Invoice.payment_intent.
|
|
const invoice = invoicesByPaymentIntent.get(payment.id);
|
|
const lines = invoice?.lines?.data[0];
|
|
// Stripe 19 dropped InvoiceLineItem.plan; the (price, product)
|
|
// identifiers now live on InvoiceLineItem.pricing.price_details.
|
|
const pricing = lines?.pricing;
|
|
const amount = payment.amount / 100;
|
|
const currency = payment.currency;
|
|
const priceId = pricing?.price_details?.price;
|
|
|
|
const productId = pricing?.price_details?.product;
|
|
|
|
const product = allProducts.find((x) => x.stripeId === productId);
|
|
let productName = "?";
|
|
let billingPeriod: SubscriptionBillingPeriod = SubscriptionBillingPeriod.DAILY;
|
|
if (product) {
|
|
productName = product.title;
|
|
}
|
|
|
|
const price = product?.prices.find((x) => x.stripeId === priceId);
|
|
if (price) {
|
|
billingPeriod = price.billingPeriod;
|
|
}
|
|
|
|
const existing = items.find((x) => x.product === productName && x.currency === currency && x.billingPeriod === billingPeriod);
|
|
|
|
let revenueUsd = amount;
|
|
if (currency !== "usd") {
|
|
revenueUsd = convertToCurrency({
|
|
from: currency,
|
|
to: "usd",
|
|
price: amount,
|
|
});
|
|
}
|
|
|
|
if (existing) {
|
|
existing.revenueInCurrency += amount;
|
|
existing.revenueUsd += revenueUsd;
|
|
existing.count++;
|
|
} else {
|
|
items.push({
|
|
product: productName,
|
|
billingPeriod,
|
|
currency,
|
|
revenueInCurrency: amount,
|
|
revenueUsd,
|
|
count: 1,
|
|
countPercentage: 0,
|
|
revenuePercentageUsd: 0,
|
|
});
|
|
}
|
|
});
|
|
|
|
// process percentages
|
|
const totalRevenue = items.reduce((x, y) => x + y.revenueUsd, 0);
|
|
const totalCount = items.reduce((x, y) => x + y.count, 0);
|
|
items.forEach((item) => {
|
|
item.countPercentage = item.count / totalCount;
|
|
item.revenuePercentageUsd = item.revenueUsd / totalRevenue;
|
|
});
|
|
|
|
// sort by revenue desc
|
|
return items.sort((x, y) => (x.revenueUsd > y.revenueUsd ? -1 : 1));
|
|
}
|