mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-13 21:01:21 +08:00
Merge dev into update-oauth-docs
This commit is contained in:
commit
efa0e63e6c
@ -0,0 +1,5 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "OneTimePurchase" ADD COLUMN "priceId" TEXT;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Subscription" ADD COLUMN "priceId" TEXT;
|
||||
@ -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;
|
||||
@ -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])
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -0,0 +1,7 @@
|
||||
import PageClient from "./page-client";
|
||||
|
||||
export default function Page() {
|
||||
return <PageClient />;
|
||||
}
|
||||
|
||||
|
||||
@ -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'
|
||||
|
||||
196
apps/dashboard/src/components/data-table/transaction-table.tsx
Normal file
196
apps/dashboard/src/components/data-table/transaction-table.tsx
Normal 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>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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) });
|
||||
});
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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`);
|
||||
};
|
||||
|
||||
@ -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",
|
||||
|
||||
22
packages/stack-shared/src/interface/crud/transactions.ts
Normal file
22
packages/stack-shared/src/interface/crud/transactions.ts
Normal 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>;
|
||||
|
||||
|
||||
@ -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" });
|
||||
|
||||
@ -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={() => {
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
@ -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>,
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user