payment email templates (#1106)

<img width="553" height="471" alt="Screenshot 2026-01-14 at 12 16 36 PM"
src="https://github.com/user-attachments/assets/9f32473d-5294-4cf7-b527-0668fb04ae47"
/>
<img width="630" height="514" alt="Screenshot 2026-01-14 at 12 17 06 PM"
src="https://github.com/user-attachments/assets/b17f57f7-148d-4438-b337-df7516d1793e"
/>

<!--

Make sure you've read the CONTRIBUTING.md guidelines:
https://github.com/stack-auth/stack-auth/blob/dev/CONTRIBUTING.md

-->


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Expanded Stripe webhooks: handles invoice and one‑time/subscription
events, sends templated payment receipt and failure emails, posts
chargeback alerts to Telegram.
* Customer invoices API plus client and UI support for listing invoices;
backend stores invoice status, total, and hosted URL.

* **Tests**
* Added end‑to‑end tests for new webhook scenarios (receipts, failures,
chargebacks) and invoices API with email outbox checks.

* **Chores**
* Centralized Telegram helpers and improved formatting, validation, and
reliability.

<sub>✏️ Tip: You can customize this high-level summary in your review
settings.</sub>
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> Introduces end-to-end invoice visibility and payment notifications.
> 
> - **Emails:** Adds default `payment_receipt` and `payment_failed`
templates and sends them from Stripe webhooks for one-time and
subscription payments (skips non‑uncollectible failures); resolves
recipients for users/teams.
> - **Webhooks:** Expands handled events; upserts invoices on
`invoice.*`; stricter unknown-type handling; adds Telegram chargeback
alert; refactors init script Telegram sending.
> - **Data model:** Extends `SubscriptionInvoice` with `status`,
`amountTotal`, `hostedInvoiceUrl` and writes them via
`upsertStripeInvoice`.
> - **API/SDK/UI:** New paginated `GET
/payments/invoices/{customer_type}/{customer_id}`; client interface
(`listInvoices`, hooks) and template Payments panel render an invoices
table.
> - **Tests:** E2E for invoices access, webhook behaviors, and email
delivery.
> 
> <sup>Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
edc8fe5651. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
This commit is contained in:
BilalG1 2026-01-20 18:45:01 -08:00 committed by GitHub
parent df888f582c
commit 373fb48e7f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 1190 additions and 74 deletions

View File

@ -0,0 +1,4 @@
ALTER TABLE "SubscriptionInvoice"
ADD COLUMN "status" TEXT,
ADD COLUMN "amountTotal" INTEGER,
ADD COLUMN "hostedInvoiceUrl" TEXT;

View File

@ -1023,6 +1023,9 @@ model SubscriptionInvoice {
stripeSubscriptionId String
stripeInvoiceId String
isSubscriptionCreationInvoice Boolean
status String?
amountTotal Int?
hostedInvoiceUrl String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

View File

@ -1,12 +1,18 @@
import { getStackStripe, getStripeForAccount, handleStripeInvoicePaid, syncStripeSubscriptions } from "@/lib/stripe";
import { getTenancy } from "@/lib/tenancies";
import { sendEmailToMany, type EmailOutboxRecipient } from "@/lib/emails";
import { listPermissions } from "@/lib/permissions";
import { getStackStripe, getStripeForAccount, syncStripeSubscriptions, upsertStripeInvoice } from "@/lib/stripe";
import { getTenancy, type Tenancy } from "@/lib/tenancies";
import { getTelegramConfig, sendTelegramMessage } from "@/lib/telegram";
import { getPrismaClientForTenancy } from "@/prisma-client";
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
import { DEFAULT_TEMPLATE_IDS } from "@stackframe/stack-shared/dist/helpers/emails";
import { yupMixed, yupNumber, yupObject, yupString, yupTuple } from "@stackframe/stack-shared/dist/schema-fields";
import { typedIncludes } from '@stackframe/stack-shared/dist/utils/arrays';
import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env";
import { StackAssertionError, StatusError, captureError } from "@stackframe/stack-shared/dist/utils/errors";
import { getOrUndefined } from "@stackframe/stack-shared/dist/utils/objects";
import { typedToUppercase } from "@stackframe/stack-shared/dist/utils/strings";
import type { StripeOverridesMap } from "@/lib/stripe-proxy";
import Stripe from "stripe";
const subscriptionChangedEvents = [
@ -19,6 +25,10 @@ const subscriptionChangedEvents = [
"customer.subscription.pending_update_applied",
"customer.subscription.pending_update_expired",
"customer.subscription.trial_will_end",
"invoice.created",
"invoice.finalized",
"invoice.updated",
"invoice.voided",
"invoice.paid",
"invoice.payment_failed",
"invoice.payment_action_required",
@ -34,32 +44,144 @@ const isSubscriptionChangedEvent = (event: Stripe.Event): event is Stripe.Event
return subscriptionChangedEvents.includes(event.type as any);
};
const paymentCustomerTypes = ["user", "team", "custom"] as const;
const formatAmount = (amountCents: number | null | undefined, currency: string | null | undefined) => {
if (typeof amountCents !== "number" || Number.isNaN(amountCents)) {
return "Amount unavailable";
}
const amount = (amountCents / 100).toFixed(2);
const normalizedCurrency = typeof currency === "string" && currency.length > 0 ? currency.toUpperCase() : "";
return normalizedCurrency ? `${normalizedCurrency} ${amount}` : amount;
};
const normalizeCustomerType = (value: string | null | undefined) => {
if (!value) {
return null;
}
const normalized = value.toLowerCase();
return typedIncludes(paymentCustomerTypes, normalized) ? normalized : null;
};
const formatStripeTimestamp = (timestampSeconds: number | null | undefined) => {
if (typeof timestampSeconds !== "number" || Number.isNaN(timestampSeconds)) {
return "Timestamp unavailable";
}
return new Date(timestampSeconds * 1000).toISOString();
};
const buildChargebackMessage = (options: {
accountId: string,
eventId: string,
tenancy: Tenancy,
dispute: Stripe.Dispute,
}) => {
const chargeId = typeof options.dispute.charge === "string" ? options.dispute.charge : null;
const paymentIntentId = typeof options.dispute.payment_intent === "string" ? options.dispute.payment_intent : null;
const lines = [
"Stripe chargeback received",
`Project: ${options.tenancy.project.display_name} (${options.tenancy.project.id})`,
`Tenancy: ${options.tenancy.id}`,
`StripeAccount: ${options.accountId}`,
`Event: ${options.eventId}`,
`Dispute: ${options.dispute.id}`,
`Amount: ${formatAmount(options.dispute.amount, options.dispute.currency)}`,
`Reason: ${options.dispute.reason}`,
`Status: ${options.dispute.status}`,
chargeId ? `Charge: ${chargeId}` : null,
paymentIntentId ? `PaymentIntent: ${paymentIntentId}` : null,
`Created: ${formatStripeTimestamp(options.dispute.created)}`,
`LiveMode: ${options.dispute.livemode ? "true" : "false"}`,
].filter((line): line is string => Boolean(line));
return lines.join("\n");
};
async function getTenancyForStripeAccountId(accountId: string, mockData?: StripeOverridesMap) {
const stripe = getStackStripe(mockData);
const account = await stripe.accounts.retrieve(accountId);
const tenancyId = account.metadata?.tenancyId;
if (!tenancyId) {
throw new StackAssertionError("Stripe account metadata missing tenancyId", { accountId });
}
const tenancy = await getTenancy(tenancyId);
if (!tenancy) {
throw new StackAssertionError("Tenancy not found", { accountId, tenancyId });
}
return tenancy;
}
async function getPaymentRecipients(options: {
tenancy: Tenancy,
prisma: Awaited<ReturnType<typeof getPrismaClientForTenancy>>,
customerType: (typeof paymentCustomerTypes)[number],
customerId: string,
}): Promise<EmailOutboxRecipient[]> {
if (options.customerType === "user") {
return [{ type: "user-primary-email", userId: options.customerId }];
}
if (options.customerType === "team") {
const permissions = await listPermissions(options.prisma, {
scope: "team",
tenancy: options.tenancy,
teamId: options.customerId,
permissionId: "team_admin",
recursive: true,
});
const userIds = [...new Set(permissions.map((permission) => permission.user_id))];
return userIds.map((userId) => ({ type: "user-primary-email", userId }));
}
return [];
}
async function sendDefaultTemplateEmail(options: {
tenancy: Tenancy,
recipients: EmailOutboxRecipient[],
templateType: keyof typeof DEFAULT_TEMPLATE_IDS,
extraVariables: Record<string, string | number>,
}) {
if (options.recipients.length === 0) {
return;
}
const templateId = DEFAULT_TEMPLATE_IDS[options.templateType];
const template = getOrUndefined(options.tenancy.config.emails.templates, templateId);
if (!template) {
throw new StackAssertionError(`Default email template not found: ${options.templateType}`, { templateId });
}
await sendEmailToMany({
tenancy: options.tenancy,
recipients: options.recipients,
tsxSource: template.tsxSource,
extraVariables: options.extraVariables,
themeId: template.themeId === false ? null : (template.themeId ?? options.tenancy.config.emails.selectedThemeId),
createdWith: { type: "programmatic-call", templateId },
isHighPriority: true,
shouldSkipDeliverabilityCheck: false,
scheduledAt: new Date(),
});
}
async function processStripeWebhookEvent(event: Stripe.Event): Promise<void> {
const mockData = (event.data.object as any).stack_stripe_mock_data;
const mockData = (event.data.object as { stack_stripe_mock_data?: StripeOverridesMap }).stack_stripe_mock_data;
if (event.type === "payment_intent.succeeded" && event.data.object.metadata.purchaseKind === "ONE_TIME") {
const metadata = event.data.object.metadata;
const paymentIntent = event.data.object as Stripe.PaymentIntent & {
charges?: { data?: Array<{ receipt_url?: string | null }> },
};
const metadata = paymentIntent.metadata;
const accountId = event.account;
if (!accountId) {
throw new StackAssertionError("Stripe webhook account id missing", { event });
}
const stripe = getStackStripe(mockData);
const account = await stripe.accounts.retrieve(accountId);
const tenancyId = account.metadata?.tenancyId;
if (!tenancyId) {
throw new StackAssertionError("Stripe account metadata missing tenancyId", { event });
}
const tenancy = await getTenancy(tenancyId);
if (!tenancy) {
throw new StackAssertionError("Tenancy not found", { event });
}
const tenancy = await getTenancyForStripeAccountId(accountId, mockData);
const prisma = await getPrismaClientForTenancy(tenancy);
const product = JSON.parse(metadata.product || "{}");
const qty = Math.max(1, Number(metadata.purchaseQuantity || 1));
const stripePaymentIntentId = event.data.object.id;
const stripePaymentIntentId = paymentIntent.id;
if (!metadata.customerId || !metadata.customerType) {
throw new StackAssertionError("Missing customer metadata for one-time purchase", { event });
}
if (!typedIncludes(["user", "team", "custom"] as const, metadata.customerType)) {
const customerType = normalizeCustomerType(metadata.customerType);
if (!customerType) {
throw new StackAssertionError("Invalid customer type for one-time purchase", { event });
}
await prisma.oneTimePurchase.upsert({
@ -72,7 +194,7 @@ async function processStripeWebhookEvent(event: Stripe.Event): Promise<void> {
create: {
tenancyId: tenancy.id,
customerId: metadata.customerId,
customerType: typedToUppercase(metadata.customerType),
customerType: typedToUppercase(customerType),
productId: metadata.productId || null,
priceId: metadata.priceId || null,
stripePaymentIntentId,
@ -87,8 +209,92 @@ async function processStripeWebhookEvent(event: Stripe.Event): Promise<void> {
quantity: qty,
}
});
const recipients = await getPaymentRecipients({
tenancy,
prisma,
customerType,
customerId: metadata.customerId,
});
const receiptLink = paymentIntent.charges?.data?.[0]?.receipt_url ?? null;
const productName = typeof product?.displayName === "string" ? product.displayName : "Purchase";
const extraVariables: Record<string, string | number> = {
productName,
quantity: qty,
amount: formatAmount(paymentIntent.amount_received, paymentIntent.currency),
};
if (receiptLink) {
extraVariables.receiptLink = receiptLink;
}
await sendDefaultTemplateEmail({
tenancy,
recipients,
templateType: "payment_receipt",
extraVariables,
});
}
if (isSubscriptionChangedEvent(event)) {
else if (event.type === "payment_intent.payment_failed" && event.data.object.metadata.purchaseKind === "ONE_TIME") {
const paymentIntent = event.data.object as Stripe.PaymentIntent;
const metadata = paymentIntent.metadata;
const accountId = event.account;
if (!accountId) {
throw new StackAssertionError("Stripe webhook account id missing", { event });
}
const tenancy = await getTenancyForStripeAccountId(accountId, mockData);
const prisma = await getPrismaClientForTenancy(tenancy);
if (!metadata.customerId || !metadata.customerType) {
throw new StackAssertionError("Missing customer metadata for one-time purchase failure", { event });
}
const customerType = normalizeCustomerType(metadata.customerType);
if (!customerType) {
throw new StackAssertionError("Invalid customer type for one-time purchase failure", { event });
}
const recipients = await getPaymentRecipients({
tenancy,
prisma,
customerType,
customerId: metadata.customerId,
});
const product = JSON.parse(metadata.product || "{}");
const productName = typeof product?.displayName === "string" ? product.displayName : "Purchase";
const failureReason = paymentIntent.last_payment_error?.message;
const extraVariables: Record<string, string | number> = {
productName,
amount: formatAmount(paymentIntent.amount, paymentIntent.currency),
};
if (failureReason) {
extraVariables.failureReason = failureReason;
}
await sendDefaultTemplateEmail({
tenancy,
recipients,
templateType: "payment_failed",
extraVariables,
});
}
else if (event.type === "charge.dispute.created") {
const telegramConfig = getTelegramConfig("chargebacks");
if (!telegramConfig) {
return;
}
const accountId = event.account;
if (!accountId) {
throw new StackAssertionError("Stripe webhook account id missing", { event });
}
const dispute = event.data.object as Stripe.Dispute;
const tenancy = await getTenancyForStripeAccountId(accountId, mockData);
const message = buildChargebackMessage({
accountId,
eventId: event.id,
tenancy,
dispute,
});
await sendTelegramMessage({
...telegramConfig,
message,
});
}
else if (isSubscriptionChangedEvent(event)) {
const accountId = event.account;
const customerId = event.data.object.customer;
if (!accountId) {
@ -100,9 +306,101 @@ async function processStripeWebhookEvent(event: Stripe.Event): Promise<void> {
const stripe = await getStripeForAccount({ accountId }, mockData);
await syncStripeSubscriptions(stripe, accountId, customerId);
if (event.type === "invoice.payment_succeeded") {
await handleStripeInvoicePaid(stripe, accountId, event.data.object);
if (event.type.startsWith("invoice.")) {
const invoice = event.data.object as Stripe.Invoice;
await upsertStripeInvoice(stripe, accountId, invoice);
}
if (event.type === "invoice.payment_succeeded") {
const invoice = event.data.object as Stripe.Invoice;
const tenancy = await getTenancyForStripeAccountId(accountId, mockData);
const prisma = await getPrismaClientForTenancy(tenancy);
const stripeCustomerId = invoice.customer;
if (typeof stripeCustomerId !== "string") {
throw new StackAssertionError("Stripe invoice customer id missing", { event });
}
const stripeCustomer = await stripe.customers.retrieve(stripeCustomerId);
if (stripeCustomer.deleted) {
throw new StackAssertionError("Stripe invoice customer deleted", { event });
}
const customerType = normalizeCustomerType(stripeCustomer.metadata.customerType);
if (!stripeCustomer.metadata.customerId || !customerType) {
throw new StackAssertionError("Stripe invoice customer metadata missing customerId or customerType", { event });
}
const recipients = await getPaymentRecipients({
tenancy,
prisma,
customerType,
customerId: stripeCustomer.metadata.customerId,
});
const invoiceLines = (invoice as { lines?: { data?: Stripe.InvoiceLineItem[] } }).lines?.data ?? [];
const lineItem = invoiceLines.length > 0 ? invoiceLines[0] : null;
const productName = lineItem?.description ?? "Subscription";
const quantity = lineItem?.quantity ?? 1;
const receiptLink = invoice.hosted_invoice_url ?? invoice.invoice_pdf ?? null;
const extraVariables: Record<string, string | number> = {
productName,
quantity,
amount: formatAmount(invoice.amount_paid, invoice.currency),
};
if (receiptLink) {
extraVariables.receiptLink = receiptLink;
}
await sendDefaultTemplateEmail({
tenancy,
recipients,
templateType: "payment_receipt",
extraVariables,
});
}
if (event.type === "invoice.payment_failed") {
const invoice = event.data.object as Stripe.Invoice;
if (invoice.status !== "uncollectible") {
return;
}
const tenancy = await getTenancyForStripeAccountId(accountId, mockData);
const prisma = await getPrismaClientForTenancy(tenancy);
const stripeCustomerId = invoice.customer;
if (typeof stripeCustomerId !== "string") {
throw new StackAssertionError("Stripe invoice customer id missing", { event });
}
const stripeCustomer = await stripe.customers.retrieve(stripeCustomerId);
if (stripeCustomer.deleted) {
throw new StackAssertionError("Stripe invoice customer deleted", { event });
}
const customerType = normalizeCustomerType(stripeCustomer.metadata.customerType);
if (!stripeCustomer.metadata.customerId || !customerType) {
throw new StackAssertionError("Stripe invoice customer metadata missing customerId or customerType", { event });
}
const recipients = await getPaymentRecipients({
tenancy,
prisma,
customerType,
customerId: stripeCustomer.metadata.customerId,
});
const invoiceLines = (invoice as { lines?: { data?: Stripe.InvoiceLineItem[] } }).lines?.data ?? [];
const lineItem = invoiceLines.length > 0 ? invoiceLines[0] : null;
const productName = lineItem?.description ?? "Subscription";
const invoiceUrl = invoice.hosted_invoice_url ?? null;
const extraVariables: Record<string, string | number> = {
productName,
amount: formatAmount(invoice.amount_due, invoice.currency),
};
if (invoiceUrl) {
extraVariables.invoiceUrl = invoiceUrl;
}
await sendDefaultTemplateEmail({
tenancy,
recipients,
templateType: "payment_failed",
extraVariables,
});
}
}
else {
throw new StackAssertionError("Unknown stripe webhook type received", { event });
}
}

View File

@ -1,11 +1,9 @@
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
import { getTelegramConfig, sendTelegramMessage } from "@/lib/telegram";
import { adaptSchema, yupArray, yupBoolean, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env";
import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors";
import { InferType } from "yup";
import { throwErr } from "@stackframe/stack-shared/dist/utils/errors";
const TELEGRAM_HOSTNAME = "api.telegram.org";
const TELEGRAM_ENDPOINT_PATH = "/sendMessage";
const STACK_TRACE_MAX_LENGTH = 4000;
const MESSAGE_PREFIX = "_".repeat(50);
@ -46,15 +44,9 @@ export const POST = createSmartRouteHandler({
}).defined(),
}),
async handler({ body }) {
const botToken = getEnvVariable("STACK_TELEGRAM_BOT_TOKEN", "");
const chatId = getEnvVariable("STACK_TELEGRAM_CHAT_ID", "");
if (!botToken || !chatId) {
throw new StackAssertionError("Telegram integration is not configured.");
}
const { botToken, chatId } = getTelegramConfig("init-stack") ?? throwErr("Telegram integration is not configured.");
const message = buildMessage(body);
await postToTelegram({ botToken, chatId, message });
await sendTelegramMessage({ botToken, chatId, message });
return {
statusCode: 200,
@ -91,35 +83,6 @@ function buildMessage(payload: InferType<typeof completionPayloadSchema>): strin
return `${MESSAGE_PREFIX}\n\n${lines.join("\n")}`;
}
async function postToTelegram({ botToken, chatId, message }: { botToken: string, chatId: string, message: string }): Promise<void> {
const response = await fetch(`https://${TELEGRAM_HOSTNAME}/bot${botToken}${TELEGRAM_ENDPOINT_PATH}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
chat_id: chatId,
text: message,
}),
});
if (!response.ok) {
const body = await safeReadBody(response);
throw new StackAssertionError("Failed to send Telegram notification.", {
status: response.status,
body,
});
}
}
async function safeReadBody(response: Response): Promise<string | undefined> {
try {
return await response.text();
} catch {
return undefined;
}
}
function safeJson(value: unknown): string {
try {
return JSON.stringify(value, null, 2);

View File

@ -0,0 +1,120 @@
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
import { ensureClientCanAccessCustomer } from "@/lib/payments";
import { getPrismaClientForTenancy } from "@/prisma-client";
import { Prisma } from "@/generated/prisma/client";
import { customerInvoicesListResponseSchema } from "@stackframe/stack-shared/dist/interface/crud/invoices";
import { adaptSchema, clientOrHigherAuthTypeSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
import { StatusError } from "@stackframe/stack-shared/dist/utils/errors";
import { typedToUppercase } from "@stackframe/stack-shared/dist/utils/strings";
export const GET = createSmartRouteHandler({
metadata: {
summary: "List invoices for a customer",
hidden: true,
tags: ["Payments"],
},
request: yupObject({
auth: yupObject({
type: clientOrHigherAuthTypeSchema.defined(),
project: adaptSchema.defined(),
tenancy: adaptSchema.defined(),
}).defined(),
params: yupObject({
customer_type: yupString().oneOf(["user", "team"]).defined(),
customer_id: yupString().defined(),
}).defined(),
query: yupObject({
cursor: yupString().optional(),
limit: yupString().optional(),
}).default(() => ({})).defined(),
}),
response: yupObject({
statusCode: yupNumber().oneOf([200]).defined(),
bodyType: yupString().oneOf(["json"]).defined(),
body: customerInvoicesListResponseSchema,
}),
handler: async ({ auth, params, query }, fullReq) => {
if (auth.type === "client") {
await ensureClientCanAccessCustomer({
customerType: params.customer_type,
customerId: params.customer_id,
user: fullReq.auth?.user,
tenancy: auth.tenancy,
forbiddenMessage: "Clients can only manage their own billing.",
});
}
const prisma = await getPrismaClientForTenancy(auth.tenancy);
const limit = yupNumber().min(1).max(100).optional().default(10).validateSync(query.limit);
const cursorId = query.cursor;
let paginationWhere: Prisma.SubscriptionInvoiceWhereInput | undefined;
if (cursorId) {
const pivot = await prisma.subscriptionInvoice.findUnique({
where: {
tenancyId_id: {
tenancyId: auth.tenancy.id,
id: cursorId,
},
},
select: { createdAt: true },
});
if (!pivot) {
throw new StatusError(400, "Invalid cursor");
}
paginationWhere = {
OR: [
{ createdAt: { lt: pivot.createdAt } },
{ AND: [{ createdAt: { equals: pivot.createdAt } }, { id: { lt: cursorId } }] },
],
};
}
const customerType = typedToUppercase(params.customer_type);
const invoices = await prisma.subscriptionInvoice.findMany({
where: {
tenancyId: auth.tenancy.id,
...(paginationWhere ?? {}),
subscription: {
customerType,
customerId: params.customer_id,
},
},
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
take: limit,
});
const invoiceStatuses = ["draft", "open", "paid", "uncollectible", "void"] as const;
type InvoiceStatus = (typeof invoiceStatuses)[number];
const allowedStatuses: ReadonlySet<InvoiceStatus> = new Set(invoiceStatuses);
const isInvoiceStatus = (value: string | null): value is InvoiceStatus => {
if (value === null) {
return false;
}
return allowedStatuses.has(value as InvoiceStatus);
};
const items = invoices.map((invoice) => {
const status = isInvoiceStatus(invoice.status) ? invoice.status : null;
return {
created_at_millis: invoice.createdAt.getTime(),
status,
amount_total: invoice.amountTotal ?? 0,
hosted_invoice_url: invoice.hostedInvoiceUrl ?? null,
};
});
const nextCursor = invoices.length === limit ? invoices[invoices.length - 1].id : null;
return {
statusCode: 200,
bodyType: "json",
body: {
items,
is_paginated: true,
pagination: {
next_cursor: nextCursor,
},
},
};
},
});

View File

@ -140,8 +140,9 @@ export async function syncStripeSubscriptions(stripe: Stripe, stripeAccountId: s
}
}
export async function handleStripeInvoicePaid(stripe: Stripe, stripeAccountId: string, invoice: Stripe.Invoice) {
const invoiceSubscriptionIds = invoice.lines.data
export async function upsertStripeInvoice(stripe: Stripe, stripeAccountId: string, invoice: Stripe.Invoice) {
const invoiceLines = (invoice as { lines?: { data?: Stripe.InvoiceLineItem[] } }).lines?.data ?? [];
const invoiceSubscriptionIds = invoiceLines
.map((line) => line.parent?.subscription_item_details?.subscription)
.filter((subscription): subscription is string => !!subscription);
if (invoiceSubscriptionIds.length === 0 || !invoice.id) {
@ -169,12 +170,18 @@ export async function handleStripeInvoicePaid(stripe: Stripe, stripeAccountId: s
update: {
stripeSubscriptionId,
isSubscriptionCreationInvoice,
status: invoice.status,
amountTotal: invoice.total,
hostedInvoiceUrl: invoice.hosted_invoice_url,
},
create: {
tenancyId: tenancy.id,
stripeSubscriptionId,
stripeInvoiceId: invoice.id,
isSubscriptionCreationInvoice,
status: invoice.status,
amountTotal: invoice.total,
hostedInvoiceUrl: invoice.hosted_invoice_url,
},
});
}

View File

@ -0,0 +1,41 @@
import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env";
import { StackAssertionError, throwErr } from "@stackframe/stack-shared/dist/utils/errors";
const TELEGRAM_HOSTNAME = "api.telegram.org";
const TELEGRAM_ENDPOINT_PATH = "/sendMessage";
export type TelegramConfig = {
botToken: string,
chatId: string,
};
export function getTelegramConfig(chatChannel: "init-stack" | "chargebacks"): TelegramConfig | null {
const botToken = getEnvVariable("STACK_TELEGRAM_BOT_TOKEN", "");
const chatIdEnv = chatChannel === "init-stack" ? "STACK_TELEGRAM_CHAT_ID" : "STACK_TELEGRAM_CHAT_ID_CHARGEBACKS";
const chatId = getEnvVariable(chatIdEnv, "");
if (!botToken || !chatId) {
return null;
}
return { botToken, chatId };
}
export async function sendTelegramMessage(options: TelegramConfig & { message: string }): Promise<void> {
const response = await fetch(`https://${TELEGRAM_HOSTNAME}/bot${options.botToken}${TELEGRAM_ENDPOINT_PATH}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
chat_id: options.chatId,
text: options.message,
}),
});
if (!response.ok) {
const body = await response.text();
throw new StackAssertionError("Failed to send Telegram notification.", {
status: response.status,
body,
});
}
}

View File

@ -0,0 +1,69 @@
import { it } from "../../../../../helpers";
import { Auth, Payments, Project, User, niceBackendFetch } from "../../../../backend-helpers";
it("should return empty invoices when payments are not set up", async ({ expect }) => {
await Project.createAndSwitch();
const { userId } = await Auth.fastSignUp();
const response = await niceBackendFetch(`/api/latest/payments/invoices/user/${userId}`, {
accessType: "client",
});
expect(response).toMatchInlineSnapshot(`
NiceResponse {
"status": 200,
"body": {
"is_paginated": true,
"items": [],
"pagination": { "next_cursor": null },
},
"headers": Headers { <some fields may have been hidden> },
}
`);
});
it("should allow a signed-in user to list their invoices", async ({ expect }) => {
await Project.createAndSwitch();
await Payments.setup();
const { userId } = await Auth.fastSignUp();
const response = await niceBackendFetch(`/api/latest/payments/invoices/user/${userId}`, {
accessType: "client",
});
expect(response.status).toBe(200);
expect(response.body).toMatchObject({
is_paginated: true,
items: expect.any(Array),
pagination: {
next_cursor: expect.toSatisfy((value: unknown) => value === null || typeof value === "string"),
},
});
for (const invoice of response.body.items as Array<Record<string, unknown>>) {
expect(invoice).toMatchObject({
created_at_millis: expect.any(Number),
status: expect.toSatisfy((value: unknown) => value === null || typeof value === "string"),
amount_total: expect.any(Number),
hosted_invoice_url: expect.toSatisfy((value: unknown) => value === null || typeof value === "string"),
});
}
});
it("should reject a signed-in user reading another user's invoices", async ({ expect }) => {
await Project.createAndSwitch();
await Payments.setup();
await Auth.fastSignUp();
const { userId: otherUserId } = await User.create();
const response = await niceBackendFetch(`/api/latest/payments/invoices/user/${otherUserId}`, {
accessType: "client",
});
expect(response).toMatchInlineSnapshot(`
NiceResponse {
"status": 403,
"body": "Clients can only manage their own billing.",
"headers": Headers { <some fields may have been hidden> },
}
`);
});

View File

@ -1,17 +1,39 @@
import { throwErr } from "@stackframe/stack-shared/dist/utils/errors";
import { wait } from "@stackframe/stack-shared/dist/utils/promises";
import { it } from "../../../../helpers";
import { niceBackendFetch, Payments, Project, User } from "../../../backend-helpers";
import { bumpEmailAddress, niceBackendFetch, Payments, Project, User } from "../../../backend-helpers";
import { getOutboxEmails } from "./emails/email-helpers";
async function waitForOutboxEmail(subject: string) {
for (let i = 0; i < 30; i++) {
const emails = await getOutboxEmails({ subject });
if (emails.length > 0) {
return emails[0];
}
await wait(500);
}
throw new Error(`Email with subject "${subject}" not found in outbox`);
}
async function waitForNoOutboxEmail(subject: string) {
for (let i = 0; i < 6; i++) {
const emails = await getOutboxEmails({ subject });
if (emails.length > 0) {
throw new Error(`Unexpected email with subject "${subject}" found in outbox`);
}
await wait(500);
}
}
it("accepts signed mock_event.succeeded webhook", async ({ expect }) => {
it("rejects signed mock_event.succeeded webhook", async ({ expect }) => {
const payload = {
id: "evt_test_1",
type: "mock_event.succeeded",
account: "acct_test123",
data: { object: { customer: "cus_test123", metadata: {} } },
};
const res = await Payments.sendStripeWebhook(payload);
expect(res.status).toBe(200);
expect(res.body).toEqual({ received: true });
await expect(Payments.sendStripeWebhook(payload)).rejects.toThrow(/Unknown stripe webhook type received/);
});
it("returns 400 on invalid signature", async ({ expect }) => {
@ -31,6 +53,17 @@ it("returns 400 on invalid signature", async ({ expect }) => {
`);
});
it("returns 500 on unknown webhook type", async ({ expect }) => {
const payload = {
id: "evt_test_unknown",
type: "unknown.event",
account: "acct_test123",
data: { object: {} },
};
await expect(Payments.sendStripeWebhook(payload)).rejects.toThrow(/Unknown stripe webhook type received/);
});
it("returns 400 when signature header is missing (schema validation)", async ({ expect }) => {
const payload = {
id: "evt_test_no_sig",
@ -42,6 +75,42 @@ it("returns 400 when signature header is missing (schema validation)", async ({
expect(res.status).toBe(400);
});
it("accepts chargeback webhooks", async ({ expect }) => {
const { code } = await Payments.createPurchaseUrlAndGetCode();
const stackTestTenancyId = (code ?? throwErr("Missing purchase code for chargeback test.")).split("_")[0];
const accountInfo = await niceBackendFetch("/api/latest/internal/payments/stripe/account-info", {
accessType: "admin",
});
expect(accountInfo.status).toBe(200);
const accountId: string = accountInfo.body.account_id;
const payload = {
id: "evt_chargeback_test",
type: "charge.dispute.created",
account: accountId,
data: {
object: {
id: "dp_test_123",
amount: 1500,
currency: "usd",
reason: "fraudulent",
status: "needs_response",
charge: "ch_test_123",
created: 1730000000,
livemode: false,
stack_stripe_mock_data: {
"accounts.retrieve": { metadata: { tenancyId: stackTestTenancyId } },
},
},
},
};
const res = await Payments.sendStripeWebhook(payload);
expect(res.status).toBe(200);
expect(res.body).toMatchInlineSnapshot(`{ "received": true }`);
});
it("deduplicates one-time purchase on payment_intent.succeeded retry", async ({ expect }) => {
await Project.createAndSwitch();
@ -143,6 +212,286 @@ it("deduplicates one-time purchase on payment_intent.succeeded retry", async ({
expect(getAfter.body.quantity).toBe(1);
});
it("sends a payment receipt email for one-time purchases", async ({ expect }) => {
const projectDisplayName = "Payments Receipt Email Test";
await Project.createAndSwitch({ display_name: projectDisplayName });
await Payments.setup();
const itemId = "receipt-credits";
const productId = "receipt-ot";
const product = {
displayName: "Receipt Credits Pack",
customerType: "user",
serverOnly: false,
stackable: true,
prices: { one: { USD: "500" } },
includedItems: { [itemId]: { quantity: 1 } },
};
await Project.updateConfig({
payments: {
items: {
[itemId]: { displayName: "Credits", customerType: "user" },
},
products: {
[productId]: product,
},
},
});
const mailbox = await bumpEmailAddress();
const { userId } = await User.create({
primary_email: mailbox.emailAddress,
primary_email_verified: true,
});
const accountInfo = await niceBackendFetch("/api/latest/internal/payments/stripe/account-info", {
accessType: "admin",
});
expect(accountInfo.status).toBe(200);
const accountId: string = accountInfo.body.account_id;
const createUrlResponse = await niceBackendFetch("/api/latest/payments/purchases/create-purchase-url", {
method: "POST",
accessType: "client",
body: {
customer_type: "user",
customer_id: userId,
product_id: productId,
},
});
expect(createUrlResponse.status).toBe(200);
const purchaseUrl = (createUrlResponse.body as { url: string }).url;
const fullCode = purchaseUrl.split("/purchase/")[1];
const stackTestTenancyId = fullCode.split("_")[0];
const receiptLink = "https://example.com/receipt/pi_test_receipt_1";
const paymentIntentId = "pi_test_receipt_1";
const payloadObj = {
id: "evt_receipt_test_1",
type: "payment_intent.succeeded",
account: accountId,
data: {
object: {
id: paymentIntentId,
customer: userId,
amount_received: 500,
currency: "usd",
charges: {
data: [{ receipt_url: receiptLink }],
},
stack_stripe_mock_data: {
"accounts.retrieve": { metadata: { tenancyId: stackTestTenancyId } },
"customers.retrieve": { metadata: { customerId: userId, customerType: "USER" } },
"subscriptions.list": { data: [] },
},
metadata: {
productId,
product: JSON.stringify(product),
customerId: userId,
customerType: "user",
purchaseQuantity: "2",
purchaseKind: "ONE_TIME",
priceId: "one",
},
},
},
};
const res = await Payments.sendStripeWebhook(payloadObj);
expect(res.status).toBe(200);
expect(res.body).toEqual({ received: true });
const email = await waitForOutboxEmail(`Your receipt from ${projectDisplayName}`);
expect(email.variables).toMatchInlineSnapshot(`
{
"amount": "USD 5.00",
"productName": "Receipt Credits Pack",
"quantity": 2,
"receiptLink": "https://example.com/receipt/pi_test_receipt_1",
}
`);
});
it("sends a payment failed email for invoice.payment_failed", async ({ expect }) => {
const projectDisplayName = "Payments Failed Email Test";
await Project.createAndSwitch({ display_name: projectDisplayName });
await Payments.setup();
const productId = "sub-failed";
const product = {
displayName: "Pro Plan",
customerType: "user",
serverOnly: false,
stackable: false,
prices: { monthly: { USD: "1500", interval: [1, "month"] } },
includedItems: {},
};
await Project.updateConfig({
payments: {
products: {
[productId]: product,
},
},
});
const mailbox = await bumpEmailAddress();
const { userId } = await User.create({
primary_email: mailbox.emailAddress,
primary_email_verified: true,
});
const accountInfo = await niceBackendFetch("/api/latest/internal/payments/stripe/account-info", {
accessType: "admin",
});
expect(accountInfo.status).toBe(200);
const accountId: string = accountInfo.body.account_id;
const createUrlResponse = await niceBackendFetch("/api/latest/payments/purchases/create-purchase-url", {
method: "POST",
accessType: "client",
body: {
customer_type: "user",
customer_id: userId,
product_id: productId,
},
});
expect(createUrlResponse.status).toBe(200);
const purchaseUrl = (createUrlResponse.body as { url: string }).url;
const fullCode = purchaseUrl.split("/purchase/")[1];
const stackTestTenancyId = fullCode.split("_")[0];
const invoiceId = "in_test_failed_1";
const invoiceUrl = "https://example.com/billing/update";
const payloadObj = {
id: "evt_invoice_failed_1",
type: "invoice.payment_failed",
account: accountId,
data: {
object: {
id: invoiceId,
customer: "cus_failed_1",
amount_due: 1500,
currency: "usd",
status: "uncollectible",
hosted_invoice_url: invoiceUrl,
lines: {
data: [
{
description: "Pro Plan",
},
],
},
stack_stripe_mock_data: {
"accounts.retrieve": { metadata: { tenancyId: stackTestTenancyId } },
"customers.retrieve": { metadata: { customerId: userId, customerType: "USER" } },
"subscriptions.list": { data: [] },
},
},
},
};
const res = await Payments.sendStripeWebhook(payloadObj);
expect(res.status).toBe(200);
expect(res.body).toEqual({ received: true });
const email = await waitForOutboxEmail(`Payment failed for ${projectDisplayName}`);
expect(email.variables).toMatchInlineSnapshot(`
{
"amount": "USD 15.00",
"invoiceUrl": "https://example.com/billing/update",
"productName": "Pro Plan",
}
`);
});
it("skips payment failed email when invoice is not uncollectible", async ({ expect }) => {
const projectDisplayName = "Payments Failed Email Open Invoice Test";
await Project.createAndSwitch({ display_name: projectDisplayName });
await Payments.setup();
const productId = "sub-failed-open";
const product = {
displayName: "Starter Plan",
customerType: "user",
serverOnly: false,
stackable: false,
prices: { monthly: { USD: "900", interval: [1, "month"] } },
includedItems: {},
};
await Project.updateConfig({
payments: {
products: {
[productId]: product,
},
},
});
const mailbox = await bumpEmailAddress();
const { userId } = await User.create({
primary_email: mailbox.emailAddress,
primary_email_verified: true,
});
const accountInfo = await niceBackendFetch("/api/latest/internal/payments/stripe/account-info", {
accessType: "admin",
});
expect(accountInfo.status).toBe(200);
const accountId: string = accountInfo.body.account_id;
const createUrlResponse = await niceBackendFetch("/api/latest/payments/purchases/create-purchase-url", {
method: "POST",
accessType: "client",
body: {
customer_type: "user",
customer_id: userId,
product_id: productId,
},
});
expect(createUrlResponse.status).toBe(200);
const purchaseUrl = (createUrlResponse.body as { url: string }).url;
const fullCode = purchaseUrl.split("/purchase/")[1];
const stackTestTenancyId = fullCode.split("_")[0];
const invoiceId = "in_test_failed_open_1";
const invoiceUrl = "https://example.com/billing/open";
const payloadObj = {
id: "evt_invoice_failed_open_1",
type: "invoice.payment_failed",
account: accountId,
data: {
object: {
id: invoiceId,
customer: "cus_failed_open_1",
amount_due: 900,
currency: "usd",
status: "open",
hosted_invoice_url: invoiceUrl,
lines: {
data: [
{
description: "Starter Plan",
},
],
},
stack_stripe_mock_data: {
"accounts.retrieve": { metadata: { tenancyId: stackTestTenancyId } },
"customers.retrieve": { metadata: { customerId: userId, customerType: "USER" } },
"subscriptions.list": { data: [] },
},
},
},
};
const res = await Payments.sendStripeWebhook(payloadObj);
expect(res.status).toBe(200);
expect(res.body).toEqual({ received: true });
await waitForNoOutboxEmail(`Payment failed for ${projectDisplayName}`);
});
it("syncs subscriptions from webhook and is idempotent", async ({ expect }) => {
await Project.createAndSwitch();

View File

@ -122,6 +122,8 @@ const EMAIL_TEMPLATE_PASSWORD_RESET_ID = "a70fb3a4-56c1-4e42-af25-49d25603abd0";
const EMAIL_TEMPLATE_MAGIC_LINK_ID = "822687fe-8d0a-4467-a0d1-416b6e639478";
const EMAIL_TEMPLATE_TEAM_INVITATION_ID = "e84de395-2076-4831-9c19-8e9a96a868e4";
const EMAIL_TEMPLATE_SIGN_IN_INVITATION_ID = "066dd73c-36da-4fd0-b6d6-ebf87683f8bc";
const EMAIL_TEMPLATE_PAYMENT_RECEIPT_ID = "70372aee-0441-4d80-974c-2e858e40123a";
const EMAIL_TEMPLATE_PAYMENT_FAILED_ID = "f64b1afe-27ec-4a28-a277-7178f3261f9a";
export const DEFAULT_EMAIL_TEMPLATES = {
[EMAIL_TEMPLATE_EMAIL_VERIFICATION_ID]: {
@ -148,6 +150,16 @@ export const DEFAULT_EMAIL_TEMPLATES = {
"displayName": "Sign In Invitation",
"tsxSource": "import { type } from \"arktype\"\nimport { Button, Section, Hr } from \"@react-email/components\";\nimport { Subject, NotificationCategory, Props } from \"@stackframe/emails\";\n\nexport const variablesSchema = type({\n signInInvitationLink: \"string\",\n teamDisplayName: \"string\"\n})\n\nexport function EmailTemplate({ user, project, variables }: Props<typeof variablesSchema.infer>) {\n return (\n <>\n <Subject\n value={\"You have been invited to sign in to \" + project.displayName}\n />\n <NotificationCategory value=\"Transactional\" />\n\n <div className=\"bg-white text-gray-900 font-sans text-base font-normal leading-normal w-full min-h-full m-0 py-8\">\n <Section>\n <h3 className=\"text-black bg-transparent font-sans font-bold text-xl text-center pt-8 px-6 m-0\">\n You are invited to sign in to {variables.teamDisplayName}\n </h3>\n\n <p className=\"text-gray-700 bg-transparent text-sm font-sans font-normal text-center pt-2 pb-4 px-6 m-0\">\n Hi\n {user.displayName ? \", \" + user.displayName : \"\"}! Please click on the following\n link to sign in to your account\n </p>\n\n <div className=\"bg-transparent text-center px-6 py-3\">\n <Button\n href={variables.signInInvitationLink}\n className=\"text-black text-sm font-sans font-bold bg-gray-200 rounded-md inline-block py-3 px-5 no-underline border-none\"\n target=\"_blank\"\n >\n Sign in\n </Button>\n </div>\n\n <div className=\"px-6 py-4 bg-transparent\">\n <Hr />\n </div>\n\n <p className=\"text-gray-700 bg-transparent text-xs font-sans font-normal text-center pt-1 pb-6 px-6 m-0\">\n If you were not expecting this email, you can safely ignore it.\n </p>\n </Section>\n </div>\n </>\n )\n}\n\nEmailTemplate.PreviewVariables = {\n signInInvitationLink: \"<sign in invitation link>\",\n teamDisplayName: \"My Team\"\n} satisfies typeof variablesSchema.infer",
"themeId": undefined,
},
[EMAIL_TEMPLATE_PAYMENT_RECEIPT_ID]: {
"displayName": "Payment Receipt",
"tsxSource": "import { type } from \"arktype\";\nimport { Button, Section, Hr } from \"@react-email/components\";\nimport { Subject, NotificationCategory, Props } from \"@stackframe/emails\";\n\nexport const variablesSchema = type({\n productName: \"string\",\n quantity: \"number\",\n amount: \"string\",\n receiptLink: \"string?\"\n});\n\nexport function EmailTemplate({ user, project, variables }: Props<typeof variablesSchema.infer>) {\n return (\n <>\n <Subject value={\"Your receipt from \" + project.displayName} />\n <NotificationCategory value=\"Transactional\" />\n <div className=\"bg-white text-[#242424] font-sans text-base font-normal tracking-[0.15008px] leading-[1.5] m-0 py-8 w-full min-h-full\">\n <Section className=\"bg-white\">\n <h3 className=\"text-black font-sans font-bold text-[20px] text-center py-4 px-6 m-0\">\n Thanks for your purchase\n </h3>\n <p className=\"text-[#474849] font-sans font-normal text-[14px] text-center pt-2 px-6 pb-4 m-0\">\n Hi{user.displayName ? (\", \" + user.displayName) : \"\"}! Your payment for {variables.productName} is complete.\n </p>\n <div className=\"px-6\">\n <p className=\"text-black font-sans font-bold text-[16px] text-center m-0\">\n Total paid: {variables.amount}\n </p>\n <p className=\"text-[#474849] font-sans text-[13px] text-center mt-2 mb-0\">\n Quantity: {variables.quantity}\n </p>\n </div>\n {variables.receiptLink ? (\n <div className=\"text-center py-4 px-6\">\n <Button\n href={variables.receiptLink}\n target=\"_blank\"\n className=\"text-black font-sans font-bold text-[14px] inline-block bg-[#f0f0f0] rounded-[4px] py-3 px-5 no-underline border-0\"\n >\n View receipt\n </Button>\n </div>\n ) : null}\n <div className=\"py-4 px-6\">\n <Hr />\n </div>\n </Section>\n </div>\n </>\n );\n}\n\nEmailTemplate.PreviewVariables = {\n productName: \"Pro Plan\",\n quantity: 1,\n amount: \"$29.00\",\n receiptLink: \"https://example.com/receipt\"\n} satisfies typeof variablesSchema.infer;\n",
"themeId": undefined,
},
[EMAIL_TEMPLATE_PAYMENT_FAILED_ID]: {
"displayName": "Payment Failed",
"tsxSource": "import { type } from \"arktype\";\nimport { Button, Section, Hr } from \"@react-email/components\";\nimport { Subject, NotificationCategory, Props } from \"@stackframe/emails\";\n\nexport const variablesSchema = type({\n productName: \"string\",\n amount: \"string\",\n invoiceUrl: \"string?\",\n failureReason: \"string?\"\n});\n\nexport function EmailTemplate({ user, project, variables }: Props<typeof variablesSchema.infer>) {\n return (\n <>\n <Subject value={\"Payment failed for \" + project.displayName} />\n <NotificationCategory value=\"Transactional\" />\n <div className=\"bg-white text-[#242424] font-sans text-base font-normal tracking-[0.15008px] leading-[1.5] m-0 py-8 w-full min-h-full\">\n <Section className=\"bg-white\">\n <h3 className=\"text-black font-sans font-bold text-[20px] text-center py-4 px-6 m-0\">\n We couldn't process your payment\n </h3>\n <p className=\"text-[#474849] font-sans font-normal text-[14px] text-center pt-2 px-6 pb-4 m-0\">\n Hi{user.displayName ? (\", \" + user.displayName) : \"\"}! Your payment for {variables.productName} ({variables.amount}) did not go through.\n </p>\n {variables.failureReason ? (\n <p className=\"text-[#8a8b8b] font-sans text-[12px] text-center px-6 pb-2 m-0\">\n Reason: {variables.failureReason}\n </p>\n ) : null}\n {variables.invoiceUrl ? (\n <div className=\"text-center py-3 px-6\">\n <Button\n href={variables.invoiceUrl}\n target=\"_blank\"\n className=\"text-black font-sans font-bold text-[14px] inline-block bg-[#f0f0f0] rounded-[4px] py-3 px-5 no-underline border-0\"\n >\n View invoice\n </Button>\n </div>\n ) : null}\n <div className=\"py-4 px-6\">\n <Hr />\n </div>\n <p className=\"text-[#474849] font-sans font-normal text-[12px] text-center pt-1 px-6 pb-6 m-0\">\n Please update your payment details to avoid service interruption.\n </p>\n </Section>\n </div>\n </>\n );\n}\n\nEmailTemplate.PreviewVariables = {\n productName: \"Pro Plan\",\n amount: \"$29.00\",\n invoiceUrl: \"https://example.com/billing\",\n failureReason: \"Your card was declined\"\n} satisfies typeof variablesSchema.infer;\n",
"themeId": undefined,
}
};
@ -157,4 +169,6 @@ export const DEFAULT_TEMPLATE_IDS = {
magic_link: EMAIL_TEMPLATE_MAGIC_LINK_ID,
team_invitation: EMAIL_TEMPLATE_TEAM_INVITATION_ID,
sign_in_invitation: EMAIL_TEMPLATE_SIGN_IN_INVITATION_ID,
payment_receipt: EMAIL_TEMPLATE_PAYMENT_RECEIPT_ID,
payment_failed: EMAIL_TEMPLATE_PAYMENT_FAILED_ID,
} as const;

View File

@ -18,6 +18,7 @@ import { urlString } from '../utils/urls';
import { ConnectedAccountAccessTokenCrud } from './crud/connected-accounts';
import { ContactChannelsCrud } from './crud/contact-channels';
import { CurrentUserCrud } from './crud/current-user';
import { CustomerInvoicesListResponse, ListCustomerInvoicesOptions } from './crud/invoices';
import { ItemCrud } from './crud/items';
import { NotificationPreferenceCrud } from './crud/notification-preferences';
import { OAuthProviderCrud } from './crud/oauth-providers';
@ -1845,6 +1846,23 @@ export class StackClientInterface {
return await response.json();
}
async listInvoices(
options: ListCustomerInvoicesOptions,
session: InternalSession | null,
): Promise<CustomerInvoicesListResponse> {
const queryParams = new URLSearchParams(filterUndefined({
cursor: options.cursor,
limit: options.limit !== undefined ? options.limit.toString() : undefined,
}));
const path = urlString`/payments/invoices/${options.customer_type}/${options.customer_id}`;
const response = await this.sendClientRequest(
`${path}${queryParams.toString() ? `?${queryParams.toString()}` : ''}`,
{},
session,
);
return await response.json();
}
async cancelSubscription(
options: {
customer_type: "user" | "team" | "custom",

View File

@ -2,7 +2,7 @@ import { CrudTypeOf, createCrud } from "../../crud";
import { jsonSchema, yupBoolean, yupMixed, yupObject, yupString } from "../../schema-fields";
export type EmailTemplateType = typeof emailTemplateTypes[number];
export const emailTemplateTypes = ['email_verification', 'password_reset', 'magic_link', 'team_invitation', 'sign_in_invitation'] as const;
export const emailTemplateTypes = ['email_verification', 'password_reset', 'magic_link', 'team_invitation', 'sign_in_invitation', 'payment_receipt', 'payment_failed'] as const;
export const emailTemplateAdminReadSchema = yupObject({
type: yupString().oneOf(emailTemplateTypes).defined(),

View File

@ -0,0 +1,36 @@
import type * as yup from "yup";
import { yupArray, yupBoolean, yupNumber, yupObject, yupString } from "../../schema-fields";
const invoiceStatusSchema = yupString().oneOf([
"draft",
"open",
"paid",
"uncollectible",
"void",
]).nullable().defined();
export const customerInvoiceReadSchema = yupObject({
created_at_millis: yupNumber().defined(),
status: invoiceStatusSchema,
amount_total: yupNumber().integer().defined(),
hosted_invoice_url: yupString().nullable().defined(),
}).defined();
export type CustomerInvoiceRead = yup.InferType<typeof customerInvoiceReadSchema>;
export const customerInvoicesListResponseSchema = yupObject({
items: yupArray(customerInvoiceReadSchema).defined(),
is_paginated: yupBoolean().oneOf([true]).defined(),
pagination: yupObject({
next_cursor: yupString().nullable().defined(),
}).defined(),
}).defined();
export type CustomerInvoicesListResponse = yup.InferType<typeof customerInvoicesListResponseSchema>;
export type ListCustomerInvoicesOptions = {
customer_type: "user" | "team",
customer_id: string,
cursor?: string,
limit?: number,
};

View File

@ -2,14 +2,15 @@
import { KnownErrors } from "@stackframe/stack-shared";
import { runAsynchronously } from "@stackframe/stack-shared/dist/utils/promises";
import { Result } from "@stackframe/stack-shared/dist/utils/results";
import { ActionDialog, Button, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Skeleton, toast, Typography } from "@stackframe/stack-ui";
import { ActionDialog, Button, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Separator, Skeleton, Table, TableBody, TableCell, TableHead, TableHeader, TableRow, toast, Typography } from "@stackframe/stack-ui";
import { CardElement, Elements, useElements, useStripe } from "@stripe/react-stripe-js";
import { loadStripe } from "@stripe/stripe-js";
import { useMemo, useState } from "react";
import { useStackApp } from "../../..";
import { useTranslation } from "../../../lib/translations";
import { Section } from "../section";
import { Result } from "@stackframe/stack-shared/dist/utils/results";
import type { CustomerInvoiceStatus, CustomerInvoicesList, CustomerInvoicesListOptions } from "../../../lib/stack-app/customers";
type PaymentMethodSummary = {
id: string,
@ -28,6 +29,48 @@ function formatPaymentMethod(pm: NonNullable<PaymentMethodSummary>) {
return details.join(" · ");
}
const formatInvoiceStatus = (status: CustomerInvoiceStatus, t: (value: string) => string) => {
if (!status) {
return t("Unknown");
}
switch (status) {
case "draft": {
return t("Draft");
}
case "open": {
return t("Open");
}
case "paid": {
return t("Paid");
}
case "uncollectible": {
return t("Uncollectible");
}
case "void": {
return t("Void");
}
default: {
return t("Unknown");
}
}
};
const formatInvoiceAmount = (amountTotal: number | null | undefined, t: (value: string) => string) => {
if (typeof amountTotal !== "number" || Number.isNaN(amountTotal)) {
return t("Unknown");
}
const normalized = amountTotal / 100;
const formatted = new Intl.NumberFormat(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(normalized);
return `$${formatted}`;
};
const formatInvoiceDate = (date: Date | null | undefined, t: (value: string) => string) => {
if (!date || Number.isNaN(date.getTime())) {
return t("Unknown");
}
return new Intl.DateTimeFormat(undefined, { year: "numeric", month: "short", day: "numeric" }).format(date);
};
type CustomerBilling = {
hasCustomer: boolean,
defaultPaymentMethod: PaymentMethodSummary,
@ -58,6 +101,7 @@ type CustomerLike = {
isCancelable: boolean,
},
}>,
useInvoices: (options?: CustomerInvoicesListOptions) => CustomerInvoicesList,
createPaymentMethodSetupIntent: () => Promise<CustomerPaymentMethodSetupIntent>,
setDefaultPaymentMethodFromSetupIntent: (setupIntentId: string) => Promise<PaymentMethodSummary>,
switchSubscription: (options: { fromProductId: string, toProductId: string, priceId?: string, quantity?: number }) => Promise<void>,
@ -187,6 +231,7 @@ function RealPaymentsPanel(props: { title?: string, customer: CustomerLike, cust
const billing = props.customer.useBilling();
const defaultPaymentMethod = billing.defaultPaymentMethod;
const products = props.customer.useProducts();
const invoices = props.customer.useInvoices({ limit: 10 });
const productsForCustomerType = products.filter(product => product.customerType === props.customerType);
const [paymentDialogOpen, setPaymentDialogOpen] = useState(false);
@ -432,6 +477,61 @@ function RealPaymentsPanel(props: { title?: string, customer: CustomerLike, cust
</Section>
)
}
{invoices.length > 0 && (
<>
<Separator />
<div className="space-y-2">
<div className="space-y-1">
<Typography className="font-medium">{t("Invoices")}</Typography>
<Typography variant="secondary" type="footnote">{t("Review past invoices and receipts.")}</Typography>
</div>
<div className="border rounded-md">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[140px]">{t("Date")}</TableHead>
<TableHead className="w-[120px]">{t("Status")}</TableHead>
<TableHead className="w-[120px]">{t("Amount")}</TableHead>
<TableHead className="w-[120px] text-right">{t("Invoice")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{invoices.map((invoice, index) => {
const createdAtTime = invoice.createdAt.getTime();
const invoiceKey = Number.isNaN(createdAtTime) ? `invoice-${index}` : `invoice-${createdAtTime}-${index}`;
return (
<TableRow key={invoiceKey}>
<TableCell>
<Typography>{formatInvoiceDate(invoice.createdAt, t)}</Typography>
</TableCell>
<TableCell>
<Typography>{formatInvoiceStatus(invoice.status, t)}</Typography>
</TableCell>
<TableCell>
<Typography>{formatInvoiceAmount(invoice.amountTotal, t)}</Typography>
</TableCell>
<TableCell align="right">
{invoice.hostedInvoiceUrl ? (
<Button asChild variant="secondary" color="neutral" size="sm">
<a href={invoice.hostedInvoiceUrl} target="_blank" rel="noreferrer">
{t("View")}
</a>
</Button>
) : (
<Typography variant="secondary" type="footnote">
{t("Unavailable")}
</Typography>
)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
</div>
</>
)}
</div >
);
}

View File

@ -2,6 +2,7 @@ import { WebAuthnError, startAuthentication, startRegistration } from "@simplewe
import { KnownErrors, StackClientInterface } from "@stackframe/stack-shared";
import { ContactChannelsCrud } from "@stackframe/stack-shared/dist/interface/crud/contact-channels";
import { CurrentUserCrud } from "@stackframe/stack-shared/dist/interface/crud/current-user";
import type { CustomerInvoicesListResponse } from "@stackframe/stack-shared/dist/interface/crud/invoices";
import { ItemCrud } from "@stackframe/stack-shared/dist/interface/crud/items";
import { NotificationPreferenceCrud } from "@stackframe/stack-shared/dist/interface/crud/notification-preferences";
import { OAuthProviderCrud } from "@stackframe/stack-shared/dist/interface/crud/oauth-providers";
@ -42,7 +43,7 @@ import { ApiKey, ApiKeyCreationOptions, ApiKeyUpdateOptions, apiKeyCreationOptio
import { ConvexCtx, GetCurrentPartialUserOptions, GetCurrentUserOptions, HandlerUrls, OAuthScopesOnSignIn, RedirectMethod, RedirectToOptions, RequestLike, TokenStoreInit, stackAppInternalsSymbol } from "../../common";
import { OAuthConnection } from "../../connected-accounts";
import { ContactChannel, ContactChannelCreateOptions, ContactChannelUpdateOptions, contactChannelCreateOptionsToCrud, contactChannelUpdateOptionsToCrud } from "../../contact-channels";
import { Customer, CustomerBilling, CustomerDefaultPaymentMethod, CustomerPaymentMethodSetupIntent, CustomerProductsList, CustomerProductsListOptions, CustomerProductsRequestOptions, Item } from "../../customers";
import { Customer, CustomerBilling, CustomerDefaultPaymentMethod, CustomerInvoiceStatus, CustomerInvoicesList, CustomerInvoicesListOptions, CustomerInvoicesRequestOptions, CustomerPaymentMethodSetupIntent, CustomerProductsList, CustomerProductsListOptions, CustomerProductsRequestOptions, Item } from "../../customers";
import { NotificationCategory } from "../../notification-categories";
import { TeamPermission } from "../../permissions";
import { AdminOwnedProject, AdminProjectUpdateOptions, Project, adminProjectCreateOptionsToCrud } from "../../projects";
@ -269,6 +270,28 @@ export class _StackClientAppImplIncomplete<HasTokenStore extends boolean, Projec
}
);
private readonly _userInvoicesCache = createCacheBySession<[string, string | null, number | null], CustomerInvoicesListResponse>(
async (session, [userId, cursor, limit]) => {
return await this._interface.listInvoices({
customer_type: "user",
customer_id: userId,
cursor: cursor ?? undefined,
limit: limit ?? undefined,
}, session);
}
);
private readonly _teamInvoicesCache = createCacheBySession<[string, string | null, number | null], CustomerInvoicesListResponse>(
async (session, [teamId, cursor, limit]) => {
return await this._interface.listInvoices({
customer_type: "team",
customer_id: teamId,
cursor: cursor ?? undefined,
limit: limit ?? undefined,
}, session);
}
);
private readonly _customerBillingCache = createCacheBySession<["user" | "team", string], {
has_customer: boolean,
default_payment_method: {
@ -1183,6 +1206,16 @@ export class _StackClientAppImplIncomplete<HasTokenStore extends boolean, Projec
return Object.assign(products, { nextCursor: response.pagination.next_cursor ?? null });
}
protected _customerInvoicesFromResponse(response: CustomerInvoicesListResponse): CustomerInvoicesList {
const invoices = response.items.map((item) => ({
status: item.status as CustomerInvoiceStatus,
amountTotal: item.amount_total,
hostedInvoiceUrl: item.hosted_invoice_url,
createdAt: new Date(item.created_at_millis),
}));
return Object.assign(invoices, { nextCursor: response.pagination.next_cursor ?? null });
}
protected _customerBillingFromResponse(response: {
has_customer: boolean,
default_payment_method: {
@ -1672,6 +1705,14 @@ export class _StackClientAppImplIncomplete<HasTokenStore extends boolean, Projec
return app.useProducts({ ...options, ...customerOptions });
},
// END_PLATFORM
async listInvoices(options?: CustomerInvoicesListOptions) {
return await app.listInvoices({ ...options, ...customerOptions });
},
// IF_PLATFORM react-like
useInvoices(options?: CustomerInvoicesListOptions) {
return app.useInvoices({ ...options, ...customerOptions });
},
// END_PLATFORM
async createCheckoutUrl(options: { productId: string, returnUrl?: string }) {
return await app._interface.createCheckoutUrl(type, userIdOrTeamId, options.productId, effectiveSession, options.returnUrl);
},
@ -1731,6 +1772,16 @@ export class _StackClientAppImplIncomplete<HasTokenStore extends boolean, Projec
return this._customerProductsFromResponse(response);
}
async listInvoices(options: CustomerInvoicesRequestOptions): Promise<CustomerInvoicesList> {
const session = await this._getSession();
if ("userId" in options) {
const response = Result.orThrow(await this._userInvoicesCache.getOrWait([session, options.userId, options.cursor ?? null, options.limit ?? null], "write-only"));
return this._customerInvoicesFromResponse(response);
}
const response = Result.orThrow(await this._teamInvoicesCache.getOrWait([session, options.teamId, options.cursor ?? null, options.limit ?? null], "write-only"));
return this._customerInvoicesFromResponse(response);
}
async cancelSubscription(options: { productId: string } | { productId: string, teamId: string }): Promise<void> {
const session = await this._getSession();
const user = await this.getUser();
@ -1750,7 +1801,6 @@ export class _StackClientAppImplIncomplete<HasTokenStore extends boolean, Projec
await this._teamProductsCache.invalidateWhere(([cachedSession, teamId]) => cachedSession === session && teamId === customerId);
}
}
// IF_PLATFORM react-like
useProducts(options: CustomerProductsRequestOptions): CustomerProductsList {
const session = this._useSession();
@ -1761,6 +1811,16 @@ export class _StackClientAppImplIncomplete<HasTokenStore extends boolean, Projec
return this._customerProductsFromResponse(response);
}
// END_PLATFORM
// IF_PLATFORM react-like
useInvoices(options: CustomerInvoicesRequestOptions): CustomerInvoicesList {
const session = this._useSession();
const cache = "userId" in options ? this._userInvoicesCache : this._teamInvoicesCache;
const debugLabel = "clientApp.useInvoices()";
const customerId = "userId" in options ? options.userId : options.teamId;
const response = useAsyncCache(cache, [session, customerId, options.cursor ?? null, options.limit ?? null] as const, debugLabel);
return this._customerInvoicesFromResponse(response);
}
// END_PLATFORM
protected _currentUserFromCrud(crud: NonNullable<CurrentUserCrud['Client']['Read']>, session: InternalSession): ProjectCurrentUser<ProjectId> {
const currentUser = withUserDestructureGuard({

View File

@ -2,7 +2,7 @@ import { KnownErrors } from "@stackframe/stack-shared";
import { CurrentUserCrud } from "@stackframe/stack-shared/dist/interface/crud/current-user";
import { Result } from "@stackframe/stack-shared/dist/utils/results";
import { AsyncStoreProperty, AuthLike, GetCurrentPartialUserOptions, GetCurrentUserOptions, HandlerUrls, OAuthScopesOnSignIn, RedirectMethod, RedirectToOptions, TokenStoreInit, stackAppInternalsSymbol } from "../../common";
import { CustomerProductsList, CustomerProductsRequestOptions, Item } from "../../customers";
import { CustomerInvoicesList, CustomerInvoicesRequestOptions, CustomerProductsList, CustomerProductsRequestOptions, Item } from "../../customers";
import { Project } from "../../projects";
import { ProjectCurrentUser, SyncedPartialUser, TokenPartialUser } from "../../users";
import { _StackClientAppImpl } from "../implementations";
@ -108,6 +108,12 @@ export type StackClientApp<HasTokenStore extends boolean = boolean, ProjectId ex
CustomerProductsList,
true
>
& AsyncStoreProperty<
"invoices",
[options: CustomerInvoicesRequestOptions],
CustomerInvoicesList,
true
>
& { [K in `redirectTo${Capitalize<keyof Omit<HandlerUrls, 'handler' | 'oauthCallback'>>}`]: (options?: RedirectToOptions) => Promise<void> }
& AuthLike<HasTokenStore extends false ? { tokenStore: TokenStoreInit } : { tokenStore?: TokenStoreInit }>
);

View File

@ -66,6 +66,28 @@ export type CustomerProductsRequestOptions =
| ({ teamId: string } & CustomerProductsListOptions)
| ({ customCustomerId: string } & CustomerProductsListOptions);
export type CustomerInvoiceStatus = "draft" | "open" | "paid" | "uncollectible" | "void" | null;
export type CustomerInvoice = {
createdAt: Date,
status: CustomerInvoiceStatus,
amountTotal: number,
hostedInvoiceUrl: string | null,
};
export type CustomerInvoicesList = CustomerInvoice[] & {
nextCursor: string | null,
};
export type CustomerInvoicesListOptions = {
cursor?: string,
limit?: number,
};
export type CustomerInvoicesRequestOptions =
| ({ userId: string } & CustomerInvoicesListOptions)
| ({ teamId: string } & CustomerInvoicesListOptions);
export type CustomerDefaultPaymentMethod = {
id: string,
brand: string | null,
@ -117,6 +139,12 @@ export type Customer<IsServer extends boolean = false> =
CustomerProductsList,
true
>
& AsyncStoreProperty<
"invoices",
[options?: CustomerInvoicesListOptions],
CustomerInvoicesList,
true
>
& (IsServer extends true ? {
grantProduct(
product: { productId: string, quantity?: number } | { product: InlineProduct, quantity?: number },