Merge dev into update-oauth-docs

This commit is contained in:
Konsti Wohlwend 2025-09-20 04:31:23 -07:00 committed by GitHub
commit efa0e63e6c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 797 additions and 24 deletions

View File

@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "OneTimePurchase" ADD COLUMN "priceId" TEXT;
-- AlterTable
ALTER TABLE "Subscription" ADD COLUMN "priceId" TEXT;

View File

@ -0,0 +1,27 @@
/*
Warnings:
- Added the required column `customerType` to the `ItemQuantityChange` table without a default value. This is not possible if the table is not empty.
*/
-- AlterTable
ALTER TABLE "ItemQuantityChange" ADD COLUMN "customerType" "CustomerType";
UPDATE "ItemQuantityChange" AS iqc
SET "customerType" = 'USER'
FROM "ProjectUser" AS pu
WHERE iqc."tenancyId" = pu."tenancyId"
AND iqc."customerId" = pu."projectUserId"::text;
UPDATE "ItemQuantityChange" AS iqc
SET "customerType" = 'TEAM'
FROM "Team" AS t
WHERE iqc."customerType" IS NULL
AND iqc."tenancyId" = t."tenancyId"
AND iqc."customerId" = t."teamId"::text;
UPDATE "ItemQuantityChange"
SET "customerType" = 'CUSTOM'
WHERE "customerType" IS NULL;
ALTER TABLE "ItemQuantityChange" ALTER COLUMN "customerType" SET NOT NULL;

View File

@ -764,6 +764,7 @@ model Subscription {
customerId String
customerType CustomerType
offerId String?
priceId String?
offer Json
quantity Int @default(1)
@ -774,38 +775,40 @@ model Subscription {
cancelAtPeriodEnd Boolean
creationSource PurchaseCreationSource
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@id([tenancyId, id])
@@unique([tenancyId, stripeSubscriptionId])
}
model ItemQuantityChange {
id String @default(uuid()) @db.Uuid
tenancyId String @db.Uuid
customerId String
itemId String
quantity Int
description String?
expiresAt DateTime?
createdAt DateTime @default(now())
id String @default(uuid()) @db.Uuid
tenancyId String @db.Uuid
customerId String
customerType CustomerType
itemId String
quantity Int
description String?
expiresAt DateTime?
createdAt DateTime @default(now())
@@id([tenancyId, id])
@@index([tenancyId, customerId, expiresAt])
}
model OneTimePurchase {
id String @default(uuid()) @db.Uuid
tenancyId String @db.Uuid
customerId String
customerType CustomerType
offerId String?
offer Json
quantity Int
id String @default(uuid()) @db.Uuid
tenancyId String @db.Uuid
customerId String
customerType CustomerType
offerId String?
priceId String?
offer Json
quantity Int
stripePaymentIntentId String?
createdAt DateTime @default(now())
creationSource PurchaseCreationSource
createdAt DateTime @default(now())
creationSource PurchaseCreationSource
@@id([tenancyId, id])
@@unique([tenancyId, stripePaymentIntentId])

View File

@ -42,7 +42,6 @@ async function processStripeWebhookEvent(event: Stripe.Event): Promise<void> {
if (!accountId) {
throw new StackAssertionError("Stripe webhook account id missing", { event });
}
console.log("Processing1", mockData);
const stripe = getStackStripe(mockData);
const account = await stripe.accounts.retrieve(accountId);
const tenancyId = account.metadata?.tenancyId;
@ -75,6 +74,7 @@ async function processStripeWebhookEvent(event: Stripe.Event): Promise<void> {
customerId: metadata.customerId,
customerType: typedToUppercase(metadata.customerType),
offerId: metadata.offerId || null,
priceId: metadata.priceId || null,
stripePaymentIntentId,
offer,
quantity: qty,
@ -82,6 +82,7 @@ async function processStripeWebhookEvent(event: Stripe.Event): Promise<void> {
},
update: {
offerId: metadata.offerId || null,
priceId: metadata.priceId || null,
offer,
quantity: qty,
}

View File

@ -55,6 +55,7 @@ export const POST = createSmartRouteHandler({
customerId: data.customerId,
customerType: typedToUppercase(data.offer.customerType),
offerId: data.offerId,
priceId: price_id,
offer: data.offer,
quantity,
creationSource: "TEST_MODE",
@ -87,6 +88,7 @@ export const POST = createSmartRouteHandler({
customerType: typedToUppercase(data.offer.customerType),
status: "active",
offerId: data.offerId,
priceId: price_id,
offer: data.offer,
quantity,
currentPeriodStart: new Date(),

View File

@ -0,0 +1,206 @@
import { getPrismaClientForTenancy } from "@/prisma-client";
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
import { Prisma } from "@prisma/client";
import { AdminTransaction, adminTransaction } from "@stackframe/stack-shared/dist/interface/crud/transactions";
import { adaptSchema, adminAuthTypeSchema, yupArray, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
import { getOrUndefined } from "@stackframe/stack-shared/dist/utils/objects";
import { typedToLowercase, typedToUppercase } from "@stackframe/stack-shared/dist/utils/strings";
type SelectedPrice = NonNullable<AdminTransaction['price']>;
type OfferWithPrices = {
displayName?: string,
prices?: Record<string, SelectedPrice & { serverOnly?: unknown, freeTrial?: unknown }> | "include-by-default",
} | null | undefined;
function resolveSelectedPriceFromOffer(offer: OfferWithPrices, priceId?: string | null): SelectedPrice | null {
if (!offer) return null;
if (!priceId) return null;
const prices = offer.prices;
if (!prices || prices === "include-by-default") return null;
const selected = prices[priceId as keyof typeof prices] as (SelectedPrice & { serverOnly?: unknown, freeTrial?: unknown }) | undefined;
if (!selected) return null;
const { serverOnly: _serverOnly, freeTrial: _freeTrial, ...rest } = selected as any;
return rest as SelectedPrice;
}
function getOfferDisplayName(offer: OfferWithPrices): string | null {
return offer?.displayName ?? null;
}
export const GET = createSmartRouteHandler({
metadata: {
hidden: true,
},
request: yupObject({
auth: yupObject({
type: adminAuthTypeSchema.defined(),
project: adaptSchema.defined(),
tenancy: adaptSchema.defined(),
}).defined(),
query: yupObject({
cursor: yupString().optional(),
limit: yupString().optional(),
type: yupString().oneOf(['subscription', 'one_time', 'item_quantity_change']).optional(),
customer_type: yupString().oneOf(['user', 'team', 'custom']).optional(),
}).optional(),
}),
response: yupObject({
statusCode: yupNumber().oneOf([200]).defined(),
bodyType: yupString().oneOf(["json"]).defined(),
body: yupObject({
transactions: yupArray(adminTransaction).defined(),
next_cursor: yupString().nullable().defined(),
}).defined(),
}),
handler: async ({ auth, query }) => {
const prisma = await getPrismaClientForTenancy(auth.tenancy);
const rawLimit = query.limit ?? "50";
const parsedLimit = Number.parseInt(rawLimit, 10);
const limit = Math.max(1, Math.min(200, Number.isFinite(parsedLimit) ? parsedLimit : 50));
const cursorStr = query.cursor ?? "";
const [subCursor, iqcCursor, otpCursor] = (cursorStr.split("|") as [string?, string?, string?]);
const paginateWhere = async <T extends "subscription" | "itemQuantityChange" | "oneTimePurchase">(
table: T,
cursorId?: string
): Promise<
T extends "subscription"
? Prisma.SubscriptionWhereInput | undefined
: T extends "itemQuantityChange"
? Prisma.ItemQuantityChangeWhereInput | undefined
: Prisma.OneTimePurchaseWhereInput | undefined
> => {
if (!cursorId) return undefined as any;
let pivot: { createdAt: Date } | null = null;
if (table === "subscription") {
pivot = await prisma.subscription.findUnique({
where: { tenancyId_id: { tenancyId: auth.tenancy.id, id: cursorId } },
select: { createdAt: true },
});
} else if (table === "itemQuantityChange") {
pivot = await prisma.itemQuantityChange.findUnique({
where: { tenancyId_id: { tenancyId: auth.tenancy.id, id: cursorId } },
select: { createdAt: true },
});
} else {
pivot = await prisma.oneTimePurchase.findUnique({
where: { tenancyId_id: { tenancyId: auth.tenancy.id, id: cursorId } },
select: { createdAt: true },
});
}
if (!pivot) return undefined as any;
return {
OR: [
{ createdAt: { lt: pivot.createdAt } },
{ AND: [{ createdAt: { equals: pivot.createdAt } }, { id: { lt: cursorId } }] },
],
} as any;
};
const [subWhere, iqcWhere, otpWhere] = await Promise.all([
paginateWhere("subscription", subCursor),
paginateWhere("itemQuantityChange", iqcCursor),
paginateWhere("oneTimePurchase", otpCursor),
]);
const baseOrder = [{ createdAt: "desc" as const }, { id: "desc" as const }];
const customerTypeFilter = query.customer_type ? { customerType: typedToUppercase(query.customer_type) } : {};
let merged: AdminTransaction[] = [];
const [subs, iqcs, otps] = await Promise.all([
(query.type === "subscription" || !query.type) ? prisma.subscription.findMany({
where: { tenancyId: auth.tenancy.id, ...(subWhere ?? {}), ...customerTypeFilter },
orderBy: baseOrder,
take: limit,
}) : [],
(query.type === "item_quantity_change" || !query.type) ? prisma.itemQuantityChange.findMany({
where: { tenancyId: auth.tenancy.id, ...(iqcWhere ?? {}), ...customerTypeFilter },
orderBy: baseOrder,
take: limit,
}) : [],
(query.type === "one_time" || !query.type) ? prisma.oneTimePurchase.findMany({
where: { tenancyId: auth.tenancy.id, ...(otpWhere ?? {}), ...customerTypeFilter },
orderBy: baseOrder,
take: limit,
}) : [],
]);
const subRows: AdminTransaction[] = subs.map((s) => ({
id: s.id,
type: 'subscription',
created_at_millis: s.createdAt.getTime(),
customer_type: typedToLowercase(s.customerType),
customer_id: s.customerId,
quantity: s.quantity,
test_mode: s.creationSource === 'TEST_MODE',
offer_display_name: getOfferDisplayName(s.offer as OfferWithPrices),
price: resolveSelectedPriceFromOffer(s.offer as OfferWithPrices, s.priceId ?? null),
status: s.status,
}));
const iqcRows: AdminTransaction[] = iqcs.map((i) => {
const itemCfg = getOrUndefined(auth.tenancy.config.payments.items, i.itemId) as { customerType?: 'user' | 'team' | 'custom' } | undefined;
const customerType = (itemCfg && itemCfg.customerType) ? itemCfg.customerType : 'custom';
return {
id: i.id,
type: 'item_quantity_change',
created_at_millis: i.createdAt.getTime(),
customer_type: customerType,
customer_id: i.customerId,
quantity: i.quantity,
test_mode: false,
offer_display_name: null,
price: null,
status: null,
item_id: i.itemId,
description: i.description ?? null,
expires_at_millis: i.expiresAt ? i.expiresAt.getTime() : null,
} as const;
});
const otpRows: AdminTransaction[] = otps.map((o) => ({
id: o.id,
type: 'one_time',
created_at_millis: o.createdAt.getTime(),
customer_type: typedToLowercase(o.customerType),
customer_id: o.customerId,
quantity: o.quantity,
test_mode: o.creationSource === 'TEST_MODE',
offer_display_name: getOfferDisplayName(o.offer as OfferWithPrices),
price: resolveSelectedPriceFromOffer(o.offer as OfferWithPrices, o.priceId ?? null),
status: null,
}));
merged = [...subRows, ...iqcRows, ...otpRows]
.sort((a, b) => (a.created_at_millis === b.created_at_millis ? (a.id < b.id ? 1 : -1) : (a.created_at_millis < b.created_at_millis ? 1 : -1)));
const page = merged.slice(0, limit);
let lastSubId = "";
let lastIqcId = "";
let lastOtpId = "";
for (const r of page) {
if (r.type === 'subscription') lastSubId = r.id;
if (r.type === 'item_quantity_change') lastIqcId = r.id;
if (r.type === 'one_time') lastOtpId = r.id;
}
const nextCursor = page.length === limit
? [lastSubId, lastIqcId, lastOtpId].join('|')
: null;
return {
statusCode: 200,
bodyType: "json",
body: {
transactions: page,
next_cursor: nextCursor,
},
};
},
});

View File

@ -72,6 +72,7 @@ export const POST = createSmartRouteHandler({
data: {
tenancyId: tenancy.id,
customerId: req.params.customer_id,
customerType: typedToUppercase(req.params.customer_type),
itemId: req.params.item_id,
quantity: req.body.delta,
description: req.body.description,

View File

@ -73,6 +73,7 @@ export const POST = createSmartRouteHandler({
metadata: {
offerId: data.offerId ?? null,
offer: JSON.stringify(data.offer),
priceId: price_id,
},
});
const clientSecretUpdated = getClientSecretFromStripeSubscription(updated);
@ -114,6 +115,7 @@ export const POST = createSmartRouteHandler({
purchaseQuantity: String(quantity),
purchaseKind: "ONE_TIME",
tenancyId: data.tenancyId,
priceId: price_id,
},
});
const clientSecret = paymentIntent.client_secret;
@ -147,6 +149,7 @@ export const POST = createSmartRouteHandler({
metadata: {
offerId: data.offerId ?? null,
offer: JSON.stringify(data.offer),
priceId: price_id,
},
});
const clientSecret = getClientSecretFromStripeSubscription(created);

View File

@ -4,8 +4,8 @@ import { CustomerType } from "@prisma/client";
import { typedIncludes } from "@stackframe/stack-shared/dist/utils/arrays";
import { getEnvVariable, getNodeEnvironment } from "@stackframe/stack-shared/dist/utils/env";
import { StackAssertionError, throwErr } from "@stackframe/stack-shared/dist/utils/errors";
import { createStripeProxy, type StripeOverridesMap } from "./stripe-proxy";
import Stripe from "stripe";
import { createStripeProxy, type StripeOverridesMap } from "./stripe-proxy";
const stripeSecretKey = getEnvVariable("STACK_STRIPE_SECRET_KEY");
const useStripeMock = stripeSecretKey === "sk_test_mockstripekey" && ["development", "test"].includes(getNodeEnvironment());
@ -79,6 +79,7 @@ export async function syncStripeSubscriptions(stripe: Stripe, stripeAccountId: s
continue;
}
const item = subscription.items.data[0];
const priceId = subscription.metadata.priceId as string | undefined;
await prisma.subscription.upsert({
where: {
tenancyId_stripeSubscriptionId: {
@ -93,12 +94,14 @@ export async function syncStripeSubscriptions(stripe: Stripe, stripeAccountId: s
currentPeriodEnd: new Date(item.current_period_end * 1000),
currentPeriodStart: new Date(item.current_period_start * 1000),
cancelAtPeriodEnd: subscription.cancel_at_period_end,
priceId: priceId ?? null,
},
create: {
tenancyId: tenancy.id,
customerId,
customerType,
offerId: subscription.metadata.offerId,
priceId: priceId ?? null,
offer: JSON.parse(subscription.metadata.offer),
quantity: item.quantity ?? 1,
stripeSubscriptionId: subscription.id,

View File

@ -0,0 +1,13 @@
"use client";
import { TransactionTable } from "@/components/data-table/transaction-table";
import { PageLayout } from "../../page-layout";
export default function PageClient() {
return (
<PageLayout title="Transactions">
<TransactionTable />
</PageLayout>
);
}

View File

@ -0,0 +1,7 @@
import PageClient from "./page-client";
export default function Page() {
return <PageClient />;
}

View File

@ -35,6 +35,7 @@ import {
Mail,
Menu,
Palette,
Receipt,
Settings,
Settings2,
ShieldEllipsis,
@ -252,6 +253,13 @@ const navigationItems: (Label | Item | Hidden)[] = [
icon: CreditCard,
type: 'item',
},
{
name: "Transactions",
href: "/payments/transactions",
regex: /^\/projects\/[^\/]+\/payments\/transactions$/,
icon: Receipt,
type: 'item',
},
{
name: "Configuration",
type: 'label'

View File

@ -0,0 +1,196 @@
'use client';
import { useAdminApp } from '@/app/(main)/(protected)/projects/[projectId]/use-admin-app';
import type { AdminTransaction } from '@stackframe/stack-shared/dist/interface/crud/transactions';
import { deepPlainEquals } from '@stackframe/stack-shared/dist/utils/objects';
import { DataTableColumnHeader, DataTableManualPagination, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, TextCell } from '@stackframe/stack-ui';
import type { ColumnDef, ColumnFiltersState, SortingState } from '@tanstack/react-table';
import React from 'react';
function formatPrice(p: AdminTransaction['price']): string {
if (!p) return '—';
const currencyKey = ('USD' in p ? 'USD' : Object.keys(p).find(k => k !== 'interval')) as string | undefined;
if (!currencyKey) return '—';
const raw = p[currencyKey as keyof typeof p] as string | undefined;
if (!raw) return '—';
const amount = Number(raw).toFixed(2).replace(/\.00$/, '');
if (Array.isArray(p.interval)) {
const [n, unit] = p.interval as [number, string];
return n === 1 ? `$${amount} / ${unit}` : `$${amount} / ${n} ${unit}`;
}
return `$${amount}`;
}
function formatDisplayType(t: AdminTransaction['type']): string {
switch (t) {
case 'subscription': {
return 'Subscription';
}
case 'one_time': {
return 'One Time';
}
case 'item_quantity_change': {
return 'Item Quantity Change';
}
default: {
return t;
}
}
}
const columns: ColumnDef<AdminTransaction>[] = [
{
accessorKey: 'type',
header: ({ column }) => <DataTableColumnHeader column={column} columnTitle="Type" />,
cell: ({ row }) => <TextCell size={100}>{formatDisplayType(row.original.type)}</TextCell>,
enableSorting: false,
},
{
accessorKey: 'customer_type',
header: ({ column }) => <DataTableColumnHeader column={column} columnTitle="Customer Type" />,
cell: ({ row }) => <TextCell>{row.original.customer_type}</TextCell>,
enableSorting: false,
},
{
accessorKey: 'customer_id',
header: ({ column }) => <DataTableColumnHeader column={column} columnTitle="Customer ID" />,
cell: ({ row }) => (
<TextCell>{row.original.customer_id}</TextCell>
),
enableSorting: false,
},
{
accessorKey: 'offer_or_item',
header: ({ column }) => <DataTableColumnHeader column={column} columnTitle="Offer / Item" />,
cell: ({ row }) => (
<TextCell>
{row.original.type === 'item_quantity_change' ? (row.original.item_id ?? '—') : (row.original.offer_display_name || '—')}
</TextCell>
),
enableSorting: false,
},
{
accessorKey: 'price',
header: ({ column }) => <DataTableColumnHeader column={column} columnTitle="Price" />,
cell: ({ row }) => <TextCell size={80}>{formatPrice(row.original.price)}</TextCell>,
enableSorting: false,
},
{
accessorKey: 'quantity',
header: ({ column }) => <DataTableColumnHeader column={column} columnTitle="Quantity" />,
cell: ({ row }) => <TextCell>{row.original.quantity}</TextCell>,
enableSorting: false,
},
{
accessorKey: 'test_mode',
header: ({ column }) => <DataTableColumnHeader column={column} columnTitle="Test Mode" />,
cell: ({ row }) => <div>{row.original.test_mode ? '✓' : ''}</div>,
enableSorting: false,
},
{
accessorKey: 'status',
header: ({ column }) => <DataTableColumnHeader column={column} columnTitle="Status" />,
cell: ({ row }) => <TextCell>{row.original.status ?? '—'}</TextCell>,
enableSorting: false,
},
{
accessorKey: 'created_at_millis',
header: ({ column }) => <DataTableColumnHeader column={column} columnTitle="Created" className="justify-end" />,
cell: ({ row }) => (
<div className="min-w-[120px] w-full text-right pr-2">{new Date(row.original.created_at_millis).toLocaleString()}</div>
),
enableSorting: false,
},
];
export function TransactionTable() {
const app = useAdminApp();
const [filters, setFilters] = React.useState<{ cursor?: string, limit?: number, type?: 'subscription' | 'one_time' | 'item_quantity_change', customerType?: 'user' | 'team' | 'custom' }>({
limit: 10,
});
const { transactions, nextCursor } = app.useTransactions(filters);
const onUpdate = async (options: {
cursor: string,
limit: number,
sorting: SortingState,
columnFilters: ColumnFiltersState,
globalFilters: any,
}) => {
const newFilters: { cursor?: string, limit?: number, type?: 'subscription' | 'one_time' | 'item_quantity_change', customerType?: 'user' | 'team' | 'custom' } = {
cursor: options.cursor,
limit: options.limit,
type: options.columnFilters.find(f => f.id === 'type')?.value as any,
customerType: options.columnFilters.find(f => f.id === 'customer_type')?.value as any,
};
if (deepPlainEquals(newFilters, filters, { ignoreUndefinedValues: true })) {
return { nextCursor: nextCursor ?? null };
}
setFilters(newFilters);
const res = await app.listTransactions(newFilters);
return { nextCursor: res.nextCursor };
};
return (
<DataTableManualPagination
columns={columns}
data={transactions}
onUpdate={onUpdate}
defaultVisibility={{
// Show only the most important columns by default
type: true,
customer_type: true,
customer_id: true,
price: true,
// Hide the rest by default; users can enable via View menu
offer_or_item: false,
quantity: false,
test_mode: true,
status: false,
created_at_millis: true,
}}
defaultColumnFilters={[
{ id: 'type', value: filters.type ?? undefined },
{ id: 'customer_type', value: filters.customerType ?? undefined },
]}
defaultSorting={[]}
toolbarRender={(table) => (
<div className="flex items-center gap-2">
<Select
value={(table.getColumn('type')?.getFilterValue() as string | undefined) ?? ''}
onValueChange={(v) => table.getColumn('type')?.setFilterValue(v === '__clear' ? undefined : v)}
>
<SelectTrigger className="h-8 w-[180px]">
<SelectValue placeholder="Filter by type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__clear">All types</SelectItem>
<SelectItem value="subscription">Subscription</SelectItem>
<SelectItem value="one_time">One-time</SelectItem>
<SelectItem value="item_quantity_change">Item quantity change</SelectItem>
</SelectContent>
</Select>
<Select
value={(table.getColumn('customer_type')?.getFilterValue() as string | undefined) ?? ''}
onValueChange={(v) => table.getColumn('customer_type')?.setFilterValue(v === '__clear' ? undefined : v)}
>
<SelectTrigger className="h-8 w-[180px]">
<SelectValue placeholder="Customer type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__clear">All customers</SelectItem>
<SelectItem value="user">User</SelectItem>
<SelectItem value="team">Team</SelectItem>
<SelectItem value="custom">Custom</SelectItem>
</SelectContent>
</Select>
</div>
)}
/>
);
}

View File

@ -0,0 +1,228 @@
import { expect } from "vitest";
import { it } from "../../../../../helpers";
import { niceBackendFetch, Payments as PaymentsHelper, Project, User, InternalProjectKeys, backendContext } from "../../../../backend-helpers";
async function setupProjectWithPaymentsConfig() {
await Project.createAndSwitch();
await PaymentsHelper.setup();
await Project.updateConfig({
payments: {
offers: {
"sub-offer": {
displayName: "Sub Offer",
customerType: "user",
serverOnly: false,
stackable: false,
prices: {
monthly: { USD: "1000", interval: [1, "month"] },
},
includedItems: {},
},
"otp-offer": {
displayName: "One-Time Offer",
customerType: "user",
serverOnly: false,
stackable: false,
prices: {
single: { USD: "5000" },
},
includedItems: {},
},
},
items: {
credits: { displayName: "Credits", customerType: "user" },
},
},
});
}
async function createPurchaseCode(options: { userId: string, offerId: string }) {
const res = await niceBackendFetch("/api/latest/payments/purchases/create-purchase-url", {
method: "POST",
accessType: "client",
body: {
customer_type: "user",
customer_id: options.userId,
offer_id: options.offerId,
},
});
expect(res.status).toBe(200);
const codeMatch = (res.body.url as string).match(/\/purchase\/([a-z0-9-_]+)/);
const code = codeMatch ? codeMatch[1] : undefined;
expect(code).toBeDefined();
return code as string;
}
it("returns empty list for fresh project", async () => {
await Project.createAndSwitch();
await PaymentsHelper.setup();
const response = await niceBackendFetch("/api/latest/internal/payments/transactions", {
accessType: "admin",
});
expect(response).toMatchInlineSnapshot(`
NiceResponse {
"status": 200,
"body": {
"next_cursor": null,
"transactions": [],
},
"headers": Headers { <some fields may have been hidden> },
}
`);
});
it("includes TEST_MODE subscription", async () => {
await setupProjectWithPaymentsConfig();
const { userId } = await User.create();
const code = await createPurchaseCode({ userId, offerId: "sub-offer" });
const testModeRes = await niceBackendFetch("/api/latest/internal/payments/test-mode-purchase-session", {
accessType: "admin",
method: "POST",
body: { full_code: code, price_id: "monthly", quantity: 1 },
});
expect(testModeRes.status).toBe(200);
const response = await niceBackendFetch("/api/latest/internal/payments/transactions", {
accessType: "admin",
});
expect(response.status).toBe(200);
expect(response.body.transactions).toMatchInlineSnapshot(`
[
{
"created_at_millis": <stripped field 'created_at_millis'>,
"customer_id": "<stripped UUID>",
"customer_type": "user",
"id": "<stripped UUID>",
"offer_display_name": "Sub Offer",
"price": {
"USD": "1000",
"interval": [
1,
"month",
],
},
"quantity": 1,
"status": "active",
"test_mode": true,
"type": "subscription",
},
]
`);
});
it("includes TEST_MODE one-time purchase", async () => {
await setupProjectWithPaymentsConfig();
const { userId } = await User.create();
const code = await createPurchaseCode({ userId, offerId: "otp-offer" });
const testModeRes = await niceBackendFetch("/api/latest/internal/payments/test-mode-purchase-session", {
accessType: "admin",
method: "POST",
body: { full_code: code, price_id: "single", quantity: 1 },
});
expect(testModeRes.status).toBe(200);
const response = await niceBackendFetch("/api/latest/internal/payments/transactions", {
accessType: "admin",
});
expect(response.status).toBe(200);
expect(response.body.transactions).toMatchInlineSnapshot(`
[
{
"created_at_millis": <stripped field 'created_at_millis'>,
"customer_id": "<stripped UUID>",
"customer_type": "user",
"id": "<stripped UUID>",
"offer_display_name": "One-Time Offer",
"price": { "USD": "5000" },
"quantity": 1,
"status": null,
"test_mode": true,
"type": "one_time",
},
]
`);
});
it("includes item quantity change entries", async () => {
await setupProjectWithPaymentsConfig();
const { userId } = await User.create();
const changeRes = await niceBackendFetch(`/api/latest/payments/items/user/${userId}/credits/update-quantity`, {
accessType: "server",
method: "POST",
query: { allow_negative: "false" },
body: { delta: 5, description: "test" },
});
expect(changeRes.status).toBe(200);
const response = await niceBackendFetch("/api/latest/internal/payments/transactions", {
accessType: "admin",
});
expect(response.status).toBe(200);
expect(response.body.transactions).toMatchInlineSnapshot(`
[
{
"created_at_millis": <stripped field 'created_at_millis'>,
"customer_id": "<stripped UUID>",
"customer_type": "user",
"description": "test",
"expires_at_millis": <stripped field 'expires_at_millis'>,
"id": "<stripped UUID>",
"item_id": "credits",
"offer_display_name": null,
"price": null,
"quantity": 5,
"status": null,
"test_mode": false,
"type": "item_quantity_change",
},
]
`);
});
it("supports concatenated cursor pagination", async () => {
await setupProjectWithPaymentsConfig();
const { userId } = await User.create();
// Make a few entries across tables
{
const code = await createPurchaseCode({ userId, offerId: "sub-offer" });
await niceBackendFetch("/api/latest/internal/payments/test-mode-purchase-session", {
accessType: "admin",
method: "POST",
body: { full_code: code, price_id: "monthly", quantity: 1 },
});
}
{
const code = await createPurchaseCode({ userId, offerId: "otp-offer" });
await niceBackendFetch("/api/latest/internal/payments/test-mode-purchase-session", {
accessType: "admin",
method: "POST",
body: { full_code: code, price_id: "single", quantity: 1 },
});
}
await niceBackendFetch(`/api/latest/payments/items/user/${userId}/credits/update-quantity`, {
accessType: "server",
method: "POST",
query: { allow_negative: "false" },
body: { delta: 2 },
});
const page1 = await niceBackendFetch("/api/latest/internal/payments/transactions", {
accessType: "admin",
query: { limit: "2" },
});
expect(page1.status).toBe(200);
expect(page1.body).toMatchObject({ next_cursor: expect.any(String) });
const page2 = await niceBackendFetch("/api/latest/internal/payments/transactions", {
accessType: "admin",
query: { limit: "2", cursor: page1.body.next_cursor },
});
expect(page2.status).toBe(200);
expect(page2.body).toMatchObject({ transactions: expect.any(Array) });
});

View File

@ -150,6 +150,7 @@ it("deduplicates one-time purchase on payment_intent.succeeded retry", async ({
customerType: "user",
purchaseQuantity: "1",
purchaseKind: "ONE_TIME",
priceId: "one",
},
},
},
@ -241,6 +242,7 @@ it("syncs subscriptions from webhook and is idempotent", async ({ expect }) => {
metadata: {
offerId,
offer: JSON.stringify(offer),
priceId: "monthly",
},
cancel_at_period_end: false,
};
@ -353,6 +355,7 @@ it("updates a user's subscriptions via webhook (add then remove)", async ({ expe
metadata: {
offerId,
offer: JSON.stringify(offer),
priceId: "monthly",
},
cancel_at_period_end: false,
};

View File

@ -232,7 +232,7 @@ export class Mailbox {
if (withSubject.length > 0) {
return withSubject;
}
await wait(200);
await wait(500);
}
throw new Error(`Message with subject ${subject} not found`);
};

View File

@ -8,8 +8,10 @@ import { ProjectPermissionDefinitionsCrud } from "./crud/project-permissions";
import { ProjectsCrud } from "./crud/projects";
import { SvixTokenCrud } from "./crud/svix-token";
import { TeamPermissionDefinitionsCrud } from "./crud/team-permissions";
import type { AdminTransaction } from "./crud/transactions";
import { ServerAuthApplicationOptions, StackServerInterface } from "./server-interface";
export type ChatContent = Array<
| { type: "text", text: string }
| { type: "tool-call", toolName: string, toolCallId: string, args: any, argsText: string, result: any }
@ -597,6 +599,21 @@ export class StackAdminInterface extends StackServerInterface {
return await response.json();
}
async listTransactions(params?: { cursor?: string, limit?: number, type?: 'subscription' | 'one_time' | 'item_quantity_change', customerType?: 'user' | 'team' | 'custom' }): Promise<{ transactions: AdminTransaction[], nextCursor: string | null }> {
const qs = new URLSearchParams();
if (params?.cursor) qs.set('cursor', params.cursor);
if (typeof params?.limit === 'number') qs.set('limit', String(params.limit));
if (params?.type) qs.set('type', params.type);
if (params?.customerType) qs.set('customer_type', params.customerType);
const response = await this.sendAdminRequest(
`/internal/payments/transactions${qs.size ? `?${qs.toString()}` : ''}`,
{ method: 'GET' },
null,
);
const json = await response.json() as { transactions: AdminTransaction[], next_cursor: string | null };
return { transactions: json.transactions, nextCursor: json.next_cursor };
}
async testModePurchase(options: { price_id: string, full_code: string, quantity?: number }): Promise<void> {
await this.sendAdminRequest(
"/internal/payments/test-mode-purchase-session",

View File

@ -0,0 +1,22 @@
import type { InferType } from "yup";
import { customerTypeSchema, offerPriceSchema, yupBoolean, yupNumber, yupObject, yupString } from "../../schema-fields";
export const adminTransaction = yupObject({
id: yupString().defined(),
type: yupString().oneOf(["subscription", "one_time", "item_quantity_change"]).defined(),
created_at_millis: yupNumber().defined(),
customer_type: customerTypeSchema.defined(),
customer_id: yupString().defined(),
quantity: yupNumber().defined(),
test_mode: yupBoolean().defined(),
offer_display_name: yupString().nullable().defined(),
price: offerPriceSchema.omit(["serverOnly", "freeTrial"]).nullable().defined(),
status: yupString().nullable().defined(),
item_id: yupString().optional(),
description: yupString().nullable().optional(),
expires_at_millis: yupNumber().nullable().optional(),
}).defined();
export type AdminTransaction = InferType<typeof adminTransaction>;

View File

@ -545,7 +545,8 @@ export const emailTemplateListSchema = yupRecord(
// Payments
export const customerTypeSchema = yupString().oneOf(['user', 'team', 'custom']);
const validateHasAtLeastOneSupportedCurrency = (value: Record<string, unknown>, context: any) => {
const validateHasAtLeastOneSupportedCurrency = (value: Record<string, unknown> | null, context: any) => {
if (!value) return true;
const currencies = Object.keys(value).filter(key => SUPPORTED_CURRENCIES.some(c => c.code === key));
if (currencies.length === 0) {
return context.createError({ message: "At least one currency is required" });

View File

@ -14,6 +14,7 @@ type DataTableToolbarProps<TData> = {
showDefaultToolbar?: boolean,
defaultColumnFilters: ColumnFiltersState,
defaultSorting: SortingState,
showResetFilters?: boolean,
}
export function DataTableToolbar<TData>({
@ -22,6 +23,7 @@ export function DataTableToolbar<TData>({
showDefaultToolbar,
defaultColumnFilters,
defaultSorting,
showResetFilters = true,
}: DataTableToolbarProps<TData>) {
const isFiltered = !deepPlainEquals(table.getState().columnFilters, defaultColumnFilters);
const isSorted = !deepPlainEquals(table.getState().sorting, defaultSorting);
@ -30,7 +32,7 @@ export function DataTableToolbar<TData>({
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 flex-wrap flex-1 stack-scope">
{toolbarRender?.(table)}
{(isFiltered || isSorted) && (
{showResetFilters && (isFiltered || isSorted) && (
<Button
variant="ghost"
onClick={() => {

View File

@ -4,6 +4,7 @@ import { InternalApiKeyCreateCrudResponse } from "@stackframe/stack-shared/dist/
import { EmailTemplateCrud } from "@stackframe/stack-shared/dist/interface/crud/email-templates";
import { InternalApiKeysCrud } from "@stackframe/stack-shared/dist/interface/crud/internal-api-keys";
import { ProjectsCrud } from "@stackframe/stack-shared/dist/interface/crud/projects";
import type { AdminTransaction } from "@stackframe/stack-shared/dist/interface/crud/transactions";
import { StackAssertionError, throwErr } from "@stackframe/stack-shared/dist/utils/errors";
import { pick } from "@stackframe/stack-shared/dist/utils/objects";
import { Result } from "@stackframe/stack-shared/dist/utils/results";
@ -73,6 +74,9 @@ export class _StackAdminAppImplIncomplete<HasTokenStore extends boolean, Project
throw error;
}
});
private readonly _transactionsCache = createCache(async ([cursor, limit, type, customerType]: [string | undefined, number | undefined, 'subscription' | 'one_time' | 'item_quantity_change' | undefined, 'user' | 'team' | 'custom' | undefined]) => {
return await this._interface.listTransactions({ cursor, limit, type, customerType });
});
constructor(options: StackAdminAppConstructorOptions<HasTokenStore, ProjectId>) {
super({
@ -586,6 +590,18 @@ export class _StackAdminAppImplIncomplete<HasTokenStore extends boolean, Project
await this._interface.testModePurchase({ price_id: options.priceId, full_code: options.fullCode, quantity: options.quantity });
}
async listTransactions(params: { cursor?: string, limit?: number, type?: 'subscription' | 'one_time' | 'item_quantity_change', customerType?: 'user' | 'team' | 'custom' }): Promise<{ transactions: AdminTransaction[], nextCursor: string | null }> {
const crud = Result.orThrow(await this._transactionsCache.getOrWait([params.cursor, params.limit, params.type, params.customerType] as const, "write-only"));
return crud;
}
// IF_PLATFORM react-like
useTransactions(params: { cursor?: string, limit?: number, type?: 'subscription' | 'one_time' | 'item_quantity_change', customerType?: 'user' | 'team' | 'custom' }): { transactions: AdminTransaction[], nextCursor: string | null } {
const data = useAsyncCache(this._transactionsCache, [params.cursor, params.limit, params.type, params.customerType] as const, "useTransactions()");
return data;
}
// END_PLATFORM
async getStripeAccountInfo(): Promise<null | { account_id: string, charges_enabled: boolean, details_submitted: boolean, payouts_enabled: boolean }> {
return await this._interface.getStripeAccountInfo();
}

View File

@ -1,4 +1,5 @@
import { ChatContent } from "@stackframe/stack-shared/dist/interface/admin-interface";
import type { AdminTransaction } from "@stackframe/stack-shared/dist/interface/crud/transactions";
import { InternalSession } from "@stackframe/stack-shared/dist/sessions";
import { Result } from "@stackframe/stack-shared/dist/utils/results";
import { AsyncStoreProperty, EmailConfig } from "../../common";
@ -36,6 +37,14 @@ export type StackAdminApp<HasTokenStore extends boolean = boolean, ProjectId ext
& AsyncStoreProperty<"emailTemplates", [], { id: string, displayName: string, themeId?: string, tsxSource: string }[], true>
& AsyncStoreProperty<"emailDrafts", [], { id: string, displayName: string, themeId: string | undefined | false, tsxSource: string, sentAt: Date | null }[], true>
& AsyncStoreProperty<"stripeAccountInfo", [], { account_id: string, charges_enabled: boolean, details_submitted: boolean, payouts_enabled: boolean } | null, false>
& AsyncStoreProperty<
"transactions",
[
{ cursor?: string, limit?: number, type?: 'subscription' | 'one_time' | 'item_quantity_change', customerType?: 'user' | 'team' | 'custom' }
],
{ transactions: AdminTransaction[], nextCursor: string | null },
true
>
& {
createInternalApiKey(options: InternalApiKeyCreateOptions): Promise<InternalApiKeyFirstView>,