mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-13 21:01:21 +08:00
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:
parent
df888f582c
commit
373fb48e7f
@ -0,0 +1,4 @@
|
||||
ALTER TABLE "SubscriptionInvoice"
|
||||
ADD COLUMN "status" TEXT,
|
||||
ADD COLUMN "amountTotal" INTEGER,
|
||||
ADD COLUMN "hostedInvoiceUrl" TEXT;
|
||||
@ -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
|
||||
|
||||
@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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,
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
41
apps/backend/src/lib/telegram.tsx
Normal file
41
apps/backend/src/lib/telegram.tsx
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -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> },
|
||||
}
|
||||
`);
|
||||
});
|
||||
@ -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();
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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(),
|
||||
|
||||
36
packages/stack-shared/src/interface/crud/invoices.ts
Normal file
36
packages/stack-shared/src/interface/crud/invoices.ts
Normal 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,
|
||||
};
|
||||
@ -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 >
|
||||
);
|
||||
}
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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 }>
|
||||
);
|
||||
|
||||
@ -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 },
|
||||
|
||||
Loading…
Reference in New Issue
Block a user