From 2afaaea96bc071a75064112479cf2fe4de226ed0 Mon Sep 17 00:00:00 2001 From: Konstantin Wohlwend Date: Fri, 19 Sep 2025 17:45:00 -0700 Subject: [PATCH 1/2] Increase mailbox wait timeout --- apps/e2e/tests/helpers.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/e2e/tests/helpers.ts b/apps/e2e/tests/helpers.ts index 183bcd1e7..4548d6310 100644 --- a/apps/e2e/tests/helpers.ts +++ b/apps/e2e/tests/helpers.ts @@ -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`); }; From ad34cfecc2c660f56e1356b6e72bd188b6a71206 Mon Sep 17 00:00:00 2001 From: BilalG1 Date: Sat, 20 Sep 2025 00:01:07 -0700 Subject: [PATCH 2/2] Transactions page (#900) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## High-level PR Summary This PR adds a new `priceId` field to the `OneTimePurchase` and `Subscription` models in the database to store Stripe price identifiers. The change includes database schema updates, corresponding migration files, and modifications to payment processing logic to properly track and store price IDs throughout the purchase flow. The implementation consistently propagates the price ID from Stripe's API responses through various payment processing endpoints and webhooks handlers, ensuring the data is properly stored and synced with the database models. ⏱️ Estimated Review Time: 15-30 minutes
💡 Review Order Suggestion | Order | File Path | |-------|-----------| | 1 | `apps/backend/prisma/schema.prisma` | | 2 | `apps/backend/prisma/migrations/20250917193043_store_price_id/migration.sql` | | 3 | `apps/backend/src/lib/stripe.tsx` | | 4 | `apps/backend/src/app/api/latest/payments/purchases/purchase-session/route.tsx` | | 5 | `apps/backend/src/app/api/latest/internal/payments/test-mode-purchase-session/route.tsx` | | 6 | `apps/backend/src/app/api/latest/integrations/stripe/webhooks/route.tsx` | | 7 | `apps/e2e/tests/backend/endpoints/api/v1/stripe-webhooks.test.ts` |
---- > [!IMPORTANT] > Add `priceId` field to track Stripe price identifiers in purchase and subscription models, updating schema, payment logic, and tests. > > - **Database Changes**: > - Add `priceId` field to `OneTimePurchase` and `Subscription` models in `schema.prisma`. > - Update database schema with migration `20250917193043_store_price_id/migration.sql`. > - **Payment Processing**: > - Update `processStripeWebhookEvent()` in `webhooks/route.tsx` to handle `priceId`. > - Modify `POST` handlers in `purchase-session/route.tsx` and `test-mode-purchase-session/route.tsx` to include `priceId` in metadata. > - Update `syncStripeSubscriptions()` in `stripe.tsx` to sync `priceId`. > - **Testing**: > - Add tests in `stripe-webhooks.test.ts` to validate `priceId` handling in webhook and purchase flows. > > This description was created by [Ellipsis](https://www.ellipsis.dev?ref=stack-auth%2Fstack-auth&utm_source=github&utm_medium=referral) for 4950494d626199f28ccc823a1f475dfed56d924b. You can [customize](https://app.ellipsis.dev/stack-auth/settings/summaries) this summary. It will automatically update as commits are pushed. ---- ## Review by RecurseML _🔍 Review performed on [e48ffa6..4950494](https://github.com/stack-auth/stack-auth/compare/e48ffa67ee4544177d8dc980536c8906edec501e...4950494d626199f28ccc823a1f475dfed56d924b)_ |   Severity   |   Location   |   Issue   |   Delete   | |:----------:|----------|-------|:--------:| | ![Medium](https://img.shields.io/badge/Medium-yellow?style=plastic) | [apps/e2e/tests/backend/endpoints/api/v1/stripe-webhooks.test.ts:153](https://github.com/stack-auth/stack-auth/pull/900#discussion_r2356623574) | The field `priceId` uses camelCase instead of the required snake_case for API parameters | [![](https://img.shields.io/badge/-lightgray?style=plastic&logo=data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0id2hpdGUiPjxwYXRoIGQ9Ik02IDE5YzAgMS4xLjkgMiAyIDJoOGMxLjEgMCAyLS45IDItMlY3SDZ2MTJ6TTE5IDRoLTMuNWwtMS0xaC01bC0xIDFINXYyaDE0VjR6Ii8+PC9zdmc+)](https://squash-322339097191.europe-west3.run.app/interactive/625f45934d9c89a3c90d718f357bd42d8eef4ee253fe1718db23788124856165/?repo_owner=stack-auth&repo_name=stack-auth&pr_number=900) | | ![Medium](https://img.shields.io/badge/Medium-yellow?style=plastic) | [apps/e2e/tests/backend/endpoints/api/v1/stripe-webhooks.test.ts:245](https://github.com/stack-auth/stack-auth/pull/900#discussion_r2356623664) | The field `priceId` uses camelCase instead of the required snake_case for API parameters | [![](https://img.shields.io/badge/-lightgray?style=plastic&logo=data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0id2hpdGUiPjxwYXRoIGQ9Ik02IDE5YzAgMS4xLjkgMiAyIDJoOGMxLjEgMCAyLS45IDItMlY3SDZ2MTJ6TTE5IDRoLTMuNWwtMS0xaC01bC0xIDFINXYyaDE0VjR6Ii8+PC9zdmc+)](https://squash-322339097191.europe-west3.run.app/interactive/90fc6f93a3390e72b126cfe52084a56880cf1ea88395fa9c81a476ccb602d4af/?repo_owner=stack-auth&repo_name=stack-auth&pr_number=900) | | ![Medium](https://img.shields.io/badge/Medium-yellow?style=plastic) | [apps/e2e/tests/backend/endpoints/api/v1/stripe-webhooks.test.ts:358](https://github.com/stack-auth/stack-auth/pull/900#discussion_r2356623721) | The field `priceId` uses camelCase instead of the required snake_case for API parameters | [![](https://img.shields.io/badge/-lightgray?style=plastic&logo=data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0id2hpdGUiPjxwYXRoIGQ9Ik02IDE5YzAgMS4xLjkgMiAyIDJoOGMxLjEgMCAyLS45IDItMlY3SDZ2MTJ6TTE5IDRoLTMuNWwtMS0xaC01bC0xIDFINXYyaDE0VjR6Ii8+PC9zdmc+)](https://squash-322339097191.europe-west3.run.app/interactive/c6ea18d2d6da35477d1eade56c71defa0e7a35512d4ca193d657c7b962868a39/?repo_owner=stack-auth&repo_name=stack-auth&pr_number=900) |
✅ Files analyzed, no issues (4) • `apps/backend/src/lib/stripe.tsx` • `apps/backend/src/app/api/latest/payments/purchases/purchase-session/route.tsx` • `apps/backend/src/app/api/latest/integrations/stripe/webhooks/route.tsx` • `apps/backend/src/app/api/latest/internal/payments/test-mode-purchase-session/route.tsx`
⏭️ Files skipped (trigger manually) (2) |   Locations   |   Trigger Analysis   | |-----------|:------------------:| `apps/backend/prisma/migrations/20250917193043_store_price_id/migration.sql` | [![Analyze](https://img.shields.io/badge/Analyze-238636?style=plastic)](https://squash-322339097191.europe-west3.run.app/interactive/8326b4d568a93d1c438396f9d3ef6137f1cc04d7c81e33912faf94418d97fb4c/?repo_owner=stack-auth&repo_name=stack-auth&pr_number=900) `apps/backend/prisma/schema.prisma` | [![Analyze](https://img.shields.io/badge/Analyze-238636?style=plastic)](https://squash-322339097191.europe-west3.run.app/interactive/8eb33747d2d1aaddde3421800f1c6f1c94e8a47c61e10870ece1ba863766aa09/?repo_owner=stack-auth&repo_name=stack-auth&pr_number=900)
[![Need help? Join our Discord](https://img.shields.io/badge/Need%20help%3F%20Join%20our%20Discord-5865F2?style=plastic&logo=discord&logoColor=white)](https://discord.gg/n3SsVDAW6U) ## Summary by CodeRabbit - New Features - Persist the selected price ID with one-time purchases and subscriptions. - Carry the price ID through payment flows and Stripe metadata for better tracking and reporting. - Test mode purchases now also record the price ID. - Chores - Database migration adds a price ID field to purchase and subscription records. - Tests - Updated end-to-end tests to validate price ID handling in webhook and purchase flows. --- .../migration.sql | 5 + .../migration.sql | 27 +++ apps/backend/prisma/schema.prisma | 41 ++-- .../integrations/stripe/webhooks/route.tsx | 3 +- .../test-mode-purchase-session/route.tsx | 2 + .../internal/payments/transactions/route.tsx | 206 ++++++++++++++++ .../[item_id]/update-quantity/route.ts | 1 + .../purchases/purchase-session/route.tsx | 3 + apps/backend/src/lib/stripe.tsx | 5 +- .../payments/transactions/page-client.tsx | 13 + .../payments/transactions/page.tsx | 7 + .../projects/[projectId]/sidebar-layout.tsx | 8 + .../data-table/transaction-table.tsx | 196 +++++++++++++++ .../api/v1/internal/transactions.test.ts | 228 ++++++++++++++++++ .../endpoints/api/v1/stripe-webhooks.test.ts | 3 + .../src/interface/admin-interface.ts | 17 ++ .../src/interface/crud/transactions.ts | 22 ++ packages/stack-shared/src/schema-fields.ts | 3 +- .../src/components/data-table/toolbar.tsx | 4 +- .../apps/implementations/admin-app-impl.ts | 16 ++ .../stack-app/apps/interfaces/admin-app.ts | 9 + 21 files changed, 796 insertions(+), 23 deletions(-) create mode 100644 apps/backend/prisma/migrations/20250917193043_store_price_id/migration.sql create mode 100644 apps/backend/prisma/migrations/20250918005821_item_quantity_change_customer_type/migration.sql create mode 100644 apps/backend/src/app/api/latest/internal/payments/transactions/route.tsx create mode 100644 apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/transactions/page-client.tsx create mode 100644 apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/transactions/page.tsx create mode 100644 apps/dashboard/src/components/data-table/transaction-table.tsx create mode 100644 apps/e2e/tests/backend/endpoints/api/v1/internal/transactions.test.ts create mode 100644 packages/stack-shared/src/interface/crud/transactions.ts diff --git a/apps/backend/prisma/migrations/20250917193043_store_price_id/migration.sql b/apps/backend/prisma/migrations/20250917193043_store_price_id/migration.sql new file mode 100644 index 000000000..8b7d4356d --- /dev/null +++ b/apps/backend/prisma/migrations/20250917193043_store_price_id/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "OneTimePurchase" ADD COLUMN "priceId" TEXT; + +-- AlterTable +ALTER TABLE "Subscription" ADD COLUMN "priceId" TEXT; diff --git a/apps/backend/prisma/migrations/20250918005821_item_quantity_change_customer_type/migration.sql b/apps/backend/prisma/migrations/20250918005821_item_quantity_change_customer_type/migration.sql new file mode 100644 index 000000000..cec427501 --- /dev/null +++ b/apps/backend/prisma/migrations/20250918005821_item_quantity_change_customer_type/migration.sql @@ -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; diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index 085e0cd0c..156fd07c1 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -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]) diff --git a/apps/backend/src/app/api/latest/integrations/stripe/webhooks/route.tsx b/apps/backend/src/app/api/latest/integrations/stripe/webhooks/route.tsx index 9c425662c..fc959604b 100644 --- a/apps/backend/src/app/api/latest/integrations/stripe/webhooks/route.tsx +++ b/apps/backend/src/app/api/latest/integrations/stripe/webhooks/route.tsx @@ -42,7 +42,6 @@ async function processStripeWebhookEvent(event: Stripe.Event): Promise { 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 { 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 { }, update: { offerId: metadata.offerId || null, + priceId: metadata.priceId || null, offer, quantity: qty, } diff --git a/apps/backend/src/app/api/latest/internal/payments/test-mode-purchase-session/route.tsx b/apps/backend/src/app/api/latest/internal/payments/test-mode-purchase-session/route.tsx index f0dde7f07..aaf665398 100644 --- a/apps/backend/src/app/api/latest/internal/payments/test-mode-purchase-session/route.tsx +++ b/apps/backend/src/app/api/latest/internal/payments/test-mode-purchase-session/route.tsx @@ -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(), diff --git a/apps/backend/src/app/api/latest/internal/payments/transactions/route.tsx b/apps/backend/src/app/api/latest/internal/payments/transactions/route.tsx new file mode 100644 index 000000000..00fa49b7d --- /dev/null +++ b/apps/backend/src/app/api/latest/internal/payments/transactions/route.tsx @@ -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; +type OfferWithPrices = { + displayName?: string, + prices?: Record | "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 ( + 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, + }, + }; + }, +}); + + diff --git a/apps/backend/src/app/api/latest/payments/items/[customer_type]/[customer_id]/[item_id]/update-quantity/route.ts b/apps/backend/src/app/api/latest/payments/items/[customer_type]/[customer_id]/[item_id]/update-quantity/route.ts index da5f86b4c..9a44eb02d 100644 --- a/apps/backend/src/app/api/latest/payments/items/[customer_type]/[customer_id]/[item_id]/update-quantity/route.ts +++ b/apps/backend/src/app/api/latest/payments/items/[customer_type]/[customer_id]/[item_id]/update-quantity/route.ts @@ -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, diff --git a/apps/backend/src/app/api/latest/payments/purchases/purchase-session/route.tsx b/apps/backend/src/app/api/latest/payments/purchases/purchase-session/route.tsx index df770396f..1ca9f23e4 100644 --- a/apps/backend/src/app/api/latest/payments/purchases/purchase-session/route.tsx +++ b/apps/backend/src/app/api/latest/payments/purchases/purchase-session/route.tsx @@ -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); diff --git a/apps/backend/src/lib/stripe.tsx b/apps/backend/src/lib/stripe.tsx index c1bcbbdd9..f452228fd 100644 --- a/apps/backend/src/lib/stripe.tsx +++ b/apps/backend/src/lib/stripe.tsx @@ -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, diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/transactions/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/transactions/page-client.tsx new file mode 100644 index 000000000..d1a04c8eb --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/transactions/page-client.tsx @@ -0,0 +1,13 @@ +"use client"; + +import { TransactionTable } from "@/components/data-table/transaction-table"; +import { PageLayout } from "../../page-layout"; + +export default function PageClient() { + return ( + + + + ); +} + diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/transactions/page.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/transactions/page.tsx new file mode 100644 index 000000000..7885f690a --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/transactions/page.tsx @@ -0,0 +1,7 @@ +import PageClient from "./page-client"; + +export default function Page() { + return ; +} + + diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx index 56a856447..ca453eca0 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx @@ -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' diff --git a/apps/dashboard/src/components/data-table/transaction-table.tsx b/apps/dashboard/src/components/data-table/transaction-table.tsx new file mode 100644 index 000000000..ff3838422 --- /dev/null +++ b/apps/dashboard/src/components/data-table/transaction-table.tsx @@ -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[] = [ + { + accessorKey: 'type', + header: ({ column }) => , + cell: ({ row }) => {formatDisplayType(row.original.type)}, + enableSorting: false, + }, + { + accessorKey: 'customer_type', + header: ({ column }) => , + cell: ({ row }) => {row.original.customer_type}, + enableSorting: false, + }, + { + accessorKey: 'customer_id', + header: ({ column }) => , + cell: ({ row }) => ( + {row.original.customer_id} + ), + enableSorting: false, + }, + { + accessorKey: 'offer_or_item', + header: ({ column }) => , + cell: ({ row }) => ( + + {row.original.type === 'item_quantity_change' ? (row.original.item_id ?? '—') : (row.original.offer_display_name || '—')} + + ), + enableSorting: false, + }, + { + accessorKey: 'price', + header: ({ column }) => , + cell: ({ row }) => {formatPrice(row.original.price)}, + enableSorting: false, + }, + { + accessorKey: 'quantity', + header: ({ column }) => , + cell: ({ row }) => {row.original.quantity}, + enableSorting: false, + }, + { + accessorKey: 'test_mode', + header: ({ column }) => , + cell: ({ row }) =>
{row.original.test_mode ? '✓' : ''}
, + enableSorting: false, + }, + { + accessorKey: 'status', + header: ({ column }) => , + cell: ({ row }) => {row.original.status ?? '—'}, + enableSorting: false, + }, + { + accessorKey: 'created_at_millis', + header: ({ column }) => , + cell: ({ row }) => ( +
{new Date(row.original.created_at_millis).toLocaleString()}
+ ), + 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 ( + ( +
+ + + +
+ )} + /> + ); +} + + diff --git a/apps/e2e/tests/backend/endpoints/api/v1/internal/transactions.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/internal/transactions.test.ts new file mode 100644 index 000000000..d95df0d6a --- /dev/null +++ b/apps/e2e/tests/backend/endpoints/api/v1/internal/transactions.test.ts @@ -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 {