diff --git a/apps/backend/prisma/migrations/20251107182739_subscription_invoice/migration.sql b/apps/backend/prisma/migrations/20251107182739_subscription_invoice/migration.sql new file mode 100644 index 0000000000..32371a51dd --- /dev/null +++ b/apps/backend/prisma/migrations/20251107182739_subscription_invoice/migration.sql @@ -0,0 +1,19 @@ +-- CreateTable +CREATE TABLE "SubscriptionInvoice" ( + "id" UUID NOT NULL, + "tenancyId" UUID NOT NULL, + "stripeSubscriptionId" TEXT NOT NULL, + "stripeInvoiceId" TEXT NOT NULL, + "isSubscriptionCreationInvoice" BOOLEAN NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "SubscriptionInvoice_pkey" PRIMARY KEY ("tenancyId","id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "SubscriptionInvoice_tenancyId_stripeInvoiceId_key" ON "SubscriptionInvoice"("tenancyId", "stripeInvoiceId"); + +-- AddForeignKey +ALTER TABLE "SubscriptionInvoice" ADD CONSTRAINT "SubscriptionInvoice_tenancyId_stripeSubscriptionId_fkey" FOREIGN KEY ("tenancyId", "stripeSubscriptionId") REFERENCES "Subscription"("tenancyId", "stripeSubscriptionId") ON DELETE RESTRICT ON UPDATE CASCADE; + diff --git a/apps/backend/prisma/migrations/20251107210602_one_time_payment_refunds/migration.sql b/apps/backend/prisma/migrations/20251107210602_one_time_payment_refunds/migration.sql new file mode 100644 index 0000000000..004d3b7cd7 --- /dev/null +++ b/apps/backend/prisma/migrations/20251107210602_one_time_payment_refunds/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "OneTimePurchase" ADD COLUMN "refundedAt" TIMESTAMP(3); + diff --git a/apps/backend/prisma/migrations/20251112215249_subscription_refunds/migration.sql b/apps/backend/prisma/migrations/20251112215249_subscription_refunds/migration.sql new file mode 100644 index 0000000000..a9754ecc4f --- /dev/null +++ b/apps/backend/prisma/migrations/20251112215249_subscription_refunds/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "Subscription" ADD COLUMN "refundedAt" TIMESTAMP(3); + diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index 346a5ccb83..fb77a57404 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -785,10 +785,14 @@ model Subscription { currentPeriodStart DateTime cancelAtPeriodEnd Boolean + refundedAt DateTime? + creationSource PurchaseCreationSource createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + SubscriptionInvoices SubscriptionInvoice[] + @@id([tenancyId, id]) @@unique([tenancyId, stripeSubscriptionId]) } @@ -819,6 +823,7 @@ model OneTimePurchase { quantity Int stripePaymentIntentId String? createdAt DateTime @default(now()) + refundedAt DateTime? creationSource PurchaseCreationSource @@id([tenancyId, id]) @@ -850,3 +855,19 @@ model CacheEntry { @@unique([namespace, cacheKey]) } + +model SubscriptionInvoice { + id String @default(uuid()) @db.Uuid + tenancyId String @db.Uuid + stripeSubscriptionId String + stripeInvoiceId String + isSubscriptionCreationInvoice Boolean + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + subscription Subscription @relation(fields: [tenancyId, stripeSubscriptionId], references: [tenancyId, stripeSubscriptionId]) + + @@id([tenancyId, id]) + @@unique([tenancyId, stripeInvoiceId]) +} 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 1fba69c1f6..b1a6ed9ef9 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 @@ -1,4 +1,4 @@ -import { getStackStripe, getStripeForAccount, syncStripeSubscriptions } from "@/lib/stripe"; +import { getStackStripe, getStripeForAccount, handleStripeInvoicePaid, syncStripeSubscriptions } from "@/lib/stripe"; import { getTenancy } from "@/lib/tenancies"; import { getPrismaClientForTenancy } from "@/prisma-client"; import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; @@ -88,7 +88,6 @@ async function processStripeWebhookEvent(event: Stripe.Event): Promise { } }); } - if (isSubscriptionChangedEvent(event)) { const accountId = event.account; const customerId = event.data.object.customer; @@ -100,6 +99,13 @@ async function processStripeWebhookEvent(event: Stripe.Event): Promise { } const stripe = await getStripeForAccount({ accountId }, mockData); await syncStripeSubscriptions(stripe, accountId, customerId); + + if (event.type == "invoice.payment_succeeded") { + await handleStripeInvoicePaid(stripe, accountId, event.data.object); + } + } + if (event.type === "refund.created") { + const refund = event.data.object; } } diff --git a/apps/backend/src/app/api/latest/internal/payments/transactions/refund/route.tsx b/apps/backend/src/app/api/latest/internal/payments/transactions/refund/route.tsx new file mode 100644 index 0000000000..83a56b4a5e --- /dev/null +++ b/apps/backend/src/app/api/latest/internal/payments/transactions/refund/route.tsx @@ -0,0 +1,123 @@ +import { getStripeForAccount } from "@/lib/stripe"; +import { getPrismaClientForTenancy } from "@/prisma-client"; +import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; +import { KnownErrors } from "@stackframe/stack-shared/dist/known-errors"; +import { adaptSchema, adminAuthTypeSchema, yupBoolean, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; +import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors"; +import { SubscriptionStatus } from "@prisma/client"; + +export const POST = createSmartRouteHandler({ + metadata: { + hidden: true, + }, + request: yupObject({ + auth: yupObject({ + type: adminAuthTypeSchema.defined(), + project: adaptSchema.defined(), + tenancy: adaptSchema.defined(), + }).defined(), + body: yupObject({ + type: yupString().oneOf(["subscription", "one-time-purchase"]).defined(), + id: yupString().defined(), + }).defined() + }), + response: yupObject({ + statusCode: yupNumber().oneOf([200]).defined(), + bodyType: yupString().oneOf(["json"]).defined(), + body: yupObject({ + success: yupBoolean().defined(), + }).defined(), + }), + handler: async ({ auth, body }) => { + const prisma = await getPrismaClientForTenancy(auth.tenancy); + if (body.type === "subscription") { + const subscription = await prisma.subscription.findUnique({ + where: { tenancyId_id: { tenancyId: auth.tenancy.id, id: body.id } }, + select: { refundedAt: true }, + }); + if (!subscription) { + throw new KnownErrors.SubscriptionInvoiceNotFound(body.id); + } + if (subscription.refundedAt) { + throw new KnownErrors.SubscriptionAlreadyRefunded(body.id); + } + const subscriptionInvoices = await prisma.subscriptionInvoice.findMany({ + where: { + tenancyId: auth.tenancy.id, + isSubscriptionCreationInvoice: true, + subscription: { + tenancyId: auth.tenancy.id, + id: body.id, + } + } + }); + if (subscriptionInvoices.length === 0) { + throw new KnownErrors.SubscriptionInvoiceNotFound(body.id); + } + if (subscriptionInvoices.length > 1) { + throw new StackAssertionError("Multiple subscription creation invoices found for subscription", { subscriptionId: body.id }); + } + const subscriptionInvoice = subscriptionInvoices[0]; + const stripe = await getStripeForAccount({ tenancy: auth.tenancy }); + const invoice = await stripe.invoices.retrieve(subscriptionInvoice.stripeInvoiceId, { expand: ["payments"] }); + const payments = invoice.payments?.data; + if (!payments || payments.length === 0) { + throw new StackAssertionError("Invoice has no payments", { invoiceId: subscriptionInvoice.stripeInvoiceId }); + } + const paidPayment = payments.find((payment) => payment.status === "paid"); + if (!paidPayment) { + throw new StackAssertionError("Invoice has no paid payment", { invoiceId: subscriptionInvoice.stripeInvoiceId }); + } + const paymentIntentId = paidPayment.payment.payment_intent; + if (!paymentIntentId || typeof paymentIntentId !== "string") { + throw new StackAssertionError("Payment has no payment intent", { invoiceId: subscriptionInvoice.stripeInvoiceId }); + } + await stripe.refunds.create({ payment_intent: paymentIntentId }); + await prisma.subscription.update({ + where: { tenancyId_id: { tenancyId: auth.tenancy.id, id: body.id } }, + data: { + status: SubscriptionStatus.canceled, + cancelAtPeriodEnd: true, + currentPeriodEnd: new Date(), + refundedAt: new Date(), + }, + }); + } else { + const purchase = await prisma.oneTimePurchase.findUnique({ + where: { tenancyId_id: { tenancyId: auth.tenancy.id, id: body.id } }, + }); + if (!purchase) { + throw new KnownErrors.OneTimePurchaseNotFound(body.id); + } + if (purchase.refundedAt) { + throw new KnownErrors.OneTimePurchaseAlreadyRefunded(body.id); + } + if (purchase.creationSource === "TEST_MODE") { + throw new KnownErrors.TestModePurchaseNonRefundable(); + } + const stripe = await getStripeForAccount({ tenancy: auth.tenancy }); + if (!purchase.stripePaymentIntentId) { + throw new KnownErrors.OneTimePurchaseNotFound(body.id); + } + await stripe.refunds.create({ + payment_intent: purchase.stripePaymentIntentId, + metadata: { + tenancyId: auth.tenancy.id, + purchaseId: purchase.id, + }, + }); + await prisma.oneTimePurchase.update({ + where: { tenancyId_id: { tenancyId: auth.tenancy.id, id: body.id } }, + data: { refundedAt: new Date() }, + }); + } + + return { + statusCode: 200, + bodyType: "json", + body: { + success: true, + }, + }; + }, +}); 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 index 1c1628ec7a..a5e529cf58 100644 --- a/apps/backend/src/app/api/latest/internal/payments/transactions/route.tsx +++ b/apps/backend/src/app/api/latest/internal/payments/transactions/route.tsx @@ -4,9 +4,14 @@ import { Prisma } from "@prisma/client"; import { TRANSACTION_TYPES, transactionSchema, type Transaction } from "@stackframe/stack-shared/dist/interface/crud/transactions"; import { adaptSchema, adminAuthTypeSchema, yupArray, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; import { typedToUppercase } from "@stackframe/stack-shared/dist/utils/strings"; -import { buildItemQuantityChangeTransaction, buildOneTimePurchaseTransaction, buildSubscriptionTransaction } from "./transaction-builder"; +import { + buildItemQuantityChangeTransaction, + buildOneTimePurchaseTransaction, + buildSubscriptionTransaction, + buildSubscriptionRenewalTransaction +} from "./transaction-builder"; -type TransactionSource = "subscription" | "item_quantity_change" | "one_time"; +type TransactionSource = "subscription" | "item_quantity_change" | "one_time" | "subscription-invoice"; export const GET = createSmartRouteHandler({ metadata: { @@ -40,9 +45,9 @@ export const GET = createSmartRouteHandler({ 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 [subCursor, iqcCursor, otpCursor, siCursor] = (cursorStr.split("|") as [string?, string?, string?, string?]); - const paginateWhere = async ( + const paginateWhere = async ( table: T, cursorId?: string ): Promise< @@ -50,7 +55,9 @@ export const GET = createSmartRouteHandler({ ? Prisma.SubscriptionWhereInput | undefined : T extends "itemQuantityChange" ? Prisma.ItemQuantityChangeWhereInput | undefined - : Prisma.OneTimePurchaseWhereInput | undefined + : T extends "oneTimePurchase" + ? Prisma.OneTimePurchaseWhereInput | undefined + : Prisma.SubscriptionInvoiceWhereInput | undefined > => { if (!cursorId) return undefined as any; let pivot: { createdAt: Date } | null = null; @@ -64,11 +71,16 @@ export const GET = createSmartRouteHandler({ where: { tenancyId_id: { tenancyId: auth.tenancy.id, id: cursorId } }, select: { createdAt: true }, }); - } else { + } else if (table === "oneTimePurchase") { pivot = await prisma.oneTimePurchase.findUnique({ where: { tenancyId_id: { tenancyId: auth.tenancy.id, id: cursorId } }, select: { createdAt: true }, }); + } else { + pivot = await prisma.subscriptionInvoice.findUnique({ + where: { tenancyId_id: { tenancyId: auth.tenancy.id, id: cursorId } }, + select: { createdAt: true } + }); } if (!pivot) return undefined as any; return { @@ -79,10 +91,11 @@ export const GET = createSmartRouteHandler({ } as any; }; - const [subWhere, iqcWhere, otpWhere] = await Promise.all([ + const [subWhere, iqcWhere, otpWhere, siWhere] = await Promise.all([ paginateWhere("subscription", subCursor), paginateWhere("itemQuantityChange", iqcCursor), paginateWhere("oneTimePurchase", otpCursor), + paginateWhere("subscriptionInvoice", siCursor) ]); const baseOrder = [{ createdAt: "desc" as const }, { id: "desc" as const }]; @@ -96,7 +109,12 @@ export const GET = createSmartRouteHandler({ }; let merged: TransactionRow[] = []; - const [subs, iqcs, otps] = await Promise.all([ + const [ + subscriptions, + itemQuantityChanges, + oneTimePayments, + subscriptionInvoices + ] = await Promise.all([ prisma.subscription.findMany({ where: { tenancyId: auth.tenancy.id, ...(subWhere ?? {}), ...customerTypeFilter }, orderBy: baseOrder, @@ -112,27 +130,49 @@ export const GET = createSmartRouteHandler({ orderBy: baseOrder, take: limit, }), + prisma.subscriptionInvoice.findMany({ + where: { + tenancyId: auth.tenancy.id, + ...(siWhere ?? {}), + subscription: customerTypeFilter, + isSubscriptionCreationInvoice: false, + }, + include: { + subscription: true + }, + orderBy: baseOrder, + take: limit, + }) ]); merged = [ - ...subs.map((subscription) => ({ + ...subscriptions.map((subscription) => ({ source: "subscription" as const, id: subscription.id, createdAt: subscription.createdAt, transaction: buildSubscriptionTransaction({ subscription }), })), - ...iqcs.map((change) => ({ + ...itemQuantityChanges.map((change) => ({ source: "item_quantity_change" as const, id: change.id, createdAt: change.createdAt, - transaction: buildItemQuantityChangeTransaction({ change, tenancy: auth.tenancy }), + transaction: buildItemQuantityChangeTransaction({ change }), })), - ...otps.map((purchase) => ({ + ...oneTimePayments.map((purchase) => ({ source: "one_time" as const, id: purchase.id, createdAt: purchase.createdAt, transaction: buildOneTimePurchaseTransaction({ purchase }), })), + ...subscriptionInvoices.map((subscriptionInvoice) => ({ + source: "subscription-invoice" as const, + id: subscriptionInvoice.id, + createdAt: subscriptionInvoice.createdAt, + transaction: buildSubscriptionRenewalTransaction({ + subscription: subscriptionInvoice.subscription, + subscriptionInvoice: subscriptionInvoice + }) + })) ].sort((a, b) => { if (a.createdAt.getTime() === b.createdAt.getTime()) { return a.id < b.id ? 1 : -1; @@ -149,14 +189,16 @@ export const GET = createSmartRouteHandler({ let lastSubId = ""; let lastIqcId = ""; let lastOtpId = ""; + let lastSiId = ""; for (const r of page) { if (r.source === "subscription") lastSubId = r.id; if (r.source === "item_quantity_change") lastIqcId = r.id; if (r.source === "one_time") lastOtpId = r.id; + if (r.source === "subscription-invoice") lastSiId = r.id; } const nextCursor = page.length === limit - ? [lastSubId, lastIqcId, lastOtpId].join('|') + ? [lastSubId, lastIqcId, lastOtpId, lastSiId].join('|') : null; return { @@ -169,4 +211,3 @@ export const GET = createSmartRouteHandler({ }; }, }); - diff --git a/apps/backend/src/app/api/latest/internal/payments/transactions/transaction-builder.ts b/apps/backend/src/app/api/latest/internal/payments/transactions/transaction-builder.ts index a2e1bf446e..a539056535 100644 --- a/apps/backend/src/app/api/latest/internal/payments/transactions/transaction-builder.ts +++ b/apps/backend/src/app/api/latest/internal/payments/transactions/transaction-builder.ts @@ -1,8 +1,7 @@ -import type { ItemQuantityChange, OneTimePurchase, Subscription } from "@prisma/client"; +import type { ItemQuantityChange, OneTimePurchase, Subscription, SubscriptionInvoice } from "@prisma/client"; import type { Transaction, TransactionEntry } from "@stackframe/stack-shared/dist/interface/crud/transactions"; import { SUPPORTED_CURRENCIES, type Currency } from "@stackframe/stack-shared/dist/utils/currency-constants"; import { typedToLowercase } from "@stackframe/stack-shared/dist/utils/strings"; -import type { Tenancy } from "@/lib/tenancies"; import { productSchema } from "@stackframe/stack-shared/dist/schema-fields"; import { InferType } from "yup"; import { productToInlineProduct } from "@/lib/payments"; @@ -27,6 +26,7 @@ export type ProductWithPrices = { type ProductSnapshot = (TransactionEntry & { type: "product_grant" })["product"]; +const REFUND_TRANSACTION_SUFFIX = ":refund"; export function resolveSelectedPriceFromProduct(product: ProductWithPrices, priceId?: string | null): SelectedPrice | null { if (!product) return null; @@ -146,6 +146,18 @@ function createProductGrantEntry(options: { }; } +function buildRefundAdjustments(options: { refundedAt?: Date | null, entries: TransactionEntry[], transactionId: string }): Transaction["adjusted_by"] { + if (!options.refundedAt) { + return []; + } + const productGrantIndex = options.entries.findIndex((entry) => entry.type === "product_grant"); + const entryIndex = productGrantIndex >= 0 ? productGrantIndex : 0; + return [{ + transaction_id: `${options.transactionId}${REFUND_TRANSACTION_SUFFIX}`, + entry_index: entryIndex, + }]; +} + export function buildSubscriptionTransaction(options: { subscription: Subscription, }): Transaction { @@ -180,13 +192,19 @@ export function buildSubscriptionTransaction(options: { entries.push(moneyTransfer); } + const adjustedBy = buildRefundAdjustments({ + refundedAt: subscription.refundedAt, + entries, + transactionId: subscription.id, + }); + return { id: subscription.id, created_at_millis: subscription.createdAt.getTime(), effective_at_millis: subscription.createdAt.getTime(), type: "purchase", entries, - adjusted_by: [], + adjusted_by: adjustedBy, test_mode: testMode, }; } @@ -225,22 +243,27 @@ export function buildOneTimePurchaseTransaction(options: { entries.push(moneyTransfer); } + const adjustedBy = buildRefundAdjustments({ + refundedAt: purchase.refundedAt, + entries, + transactionId: purchase.id, + }); + return { id: purchase.id, created_at_millis: purchase.createdAt.getTime(), effective_at_millis: purchase.createdAt.getTime(), type: "purchase", entries, - adjusted_by: [], + adjusted_by: adjustedBy, test_mode: testMode, }; } export function buildItemQuantityChangeTransaction(options: { change: ItemQuantityChange, - tenancy: Tenancy, }): Transaction { - const { change, tenancy } = options; + const { change } = options; const customerType = typedToLowercase(change.customerType); const entries: TransactionEntry[] = [ @@ -265,3 +288,36 @@ export function buildItemQuantityChangeTransaction(options: { test_mode: false, }; } + +export function buildSubscriptionRenewalTransaction(options: { + subscription: Subscription, + subscriptionInvoice: SubscriptionInvoice, +}): Transaction { + const { subscription, subscriptionInvoice } = options; + const product = subscription.product as InferType; + const selectedPrice = resolveSelectedPriceFromProduct(product, subscription.priceId ?? null); + const chargedAmount = buildChargedAmount(selectedPrice, subscription.quantity); + + const entries: TransactionEntry[] = [ + { + type: "money_transfer", + adjusted_transaction_id: null, + adjusted_entry_index: null, + charged_amount: chargedAmount, + // todo: store net amount + net_amount: { USD: chargedAmount.USD }, + customer_id: subscription.customerId, + customer_type: typedToLowercase(subscription.customerType), + }, + ]; + + return { + type: "subscription-renewal", + id: subscriptionInvoice.id, + test_mode: false, + entries, + adjusted_by: [], + created_at_millis: subscriptionInvoice.createdAt.getTime(), + effective_at_millis: subscriptionInvoice.createdAt.getTime(), + }; +} diff --git a/apps/backend/src/lib/payments.tsx b/apps/backend/src/lib/payments.tsx index 1865db6ba9..e59a832055 100644 --- a/apps/backend/src/lib/payments.tsx +++ b/apps/backend/src/lib/payments.tsx @@ -675,6 +675,7 @@ export async function getOwnedProductsForCustomer(options: { tenancyId: options.tenancy.id, customerId: options.customerId, customerType: typedToUppercase(options.customerType), + refundedAt: null, }, }), ]); diff --git a/apps/backend/src/lib/stripe.tsx b/apps/backend/src/lib/stripe.tsx index d7326c4fe9..7451726d6f 100644 --- a/apps/backend/src/lib/stripe.tsx +++ b/apps/backend/src/lib/stripe.tsx @@ -47,11 +47,20 @@ export const getStripeForAccount = async (options: { tenancy?: Tenancy, accountI return createStripeProxy(new Stripe(stripeSecretKey, { stripeAccount: accountId, ...stripeConfig }), overrides); }; -export async function syncStripeSubscriptions(stripe: Stripe, stripeAccountId: string, stripeCustomerId: string) { +const getTenancyFromStripeAccountIdOrThrow = async (stripe: Stripe, stripeAccountId: string) => { const account = await stripe.accounts.retrieve(stripeAccountId); - if (!account.metadata?.tenancyId) { - throwErr(500, "Stripe account metadata missing tenancyId"); + if (!account.metadata?.tenancyId || typeof account.metadata.tenancyId !== "string") { + throw new StackAssertionError("Stripe account metadata missing tenancyId", { accountId: stripeAccountId }); + } + const tenancy = await getTenancy(account.metadata.tenancyId); + if (!tenancy) { + throw new StackAssertionError("Tenancy not found", { accountId: stripeAccountId }); } + return tenancy; +}; + +export async function syncStripeSubscriptions(stripe: Stripe, stripeAccountId: string, stripeCustomerId: string) { + const tenancy = await getTenancyFromStripeAccountIdOrThrow(stripe, stripeAccountId); const stripeCustomer = await stripe.customers.retrieve(stripeCustomerId); if (stripeCustomer.deleted) { return; @@ -64,10 +73,6 @@ export async function syncStripeSubscriptions(stripe: Stripe, stripeAccountId: s if (!typedIncludes(Object.values(CustomerType), customerType)) { throw new StackAssertionError("Stripe customer metadata has invalid customerType"); } - const tenancy = await getTenancy(account.metadata.tenancyId); - if (!tenancy) { - throw new StackAssertionError("Tenancy not found"); - } const prisma = await getPrismaClientForTenancy(tenancy); const subscriptions = await stripe.subscriptions.list({ customer: stripeCustomerId, @@ -115,3 +120,42 @@ export async function syncStripeSubscriptions(stripe: Stripe, stripeAccountId: s }); } } + +export async function handleStripeInvoicePaid(stripe: Stripe, stripeAccountId: string, invoice: Stripe.Invoice) { + const invoiceSubscriptionIds = invoice.lines.data + .map((line) => line.parent?.subscription_item_details?.subscription) + .filter((subscription): subscription is string => !!subscription); + if (invoiceSubscriptionIds.length === 0 || !invoice.id) { + return; + } + if (invoiceSubscriptionIds.length > 1) { + throw new StackAssertionError( + "Multiple subscription line items found in single invoice", + { stripeAccountId, invoiceId: invoice.id } + ); + } + + const stripeSubscriptionId = invoiceSubscriptionIds[0]; + const isSubscriptionCreationInvoice = invoice.billing_reason === "subscription_create"; + const tenancy = await getTenancyFromStripeAccountIdOrThrow(stripe, stripeAccountId); + const prisma = await getPrismaClientForTenancy(tenancy); + + await prisma.subscriptionInvoice.upsert({ + where: { + tenancyId_stripeInvoiceId: { + tenancyId: tenancy.id, + stripeInvoiceId: invoice.id, + }, + }, + update: { + stripeSubscriptionId, + isSubscriptionCreationInvoice, + }, + create: { + tenancyId: tenancy.id, + stripeSubscriptionId, + stripeInvoiceId: invoice.id, + isSubscriptionCreationInvoice, + }, + }); +} diff --git a/apps/dashboard/src/components/data-table/transaction-table.tsx b/apps/dashboard/src/components/data-table/transaction-table.tsx index 717212c471..8bad3384b3 100644 --- a/apps/dashboard/src/components/data-table/transaction-table.tsx +++ b/apps/dashboard/src/components/data-table/transaction-table.tsx @@ -4,10 +4,10 @@ import { useAdminApp } from '@/app/(main)/(protected)/projects/[projectId]/use-a import type { Transaction, TransactionEntry, TransactionType } from '@stackframe/stack-shared/dist/interface/crud/transactions'; import { TRANSACTION_TYPES } from '@stackframe/stack-shared/dist/interface/crud/transactions'; import { deepPlainEquals } from '@stackframe/stack-shared/dist/utils/objects'; -import { AvatarCell, DataTableColumnHeader, DataTableManualPagination, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, TextCell, Tooltip, TooltipContent, TooltipTrigger } from '@stackframe/stack-ui'; +import { ActionCell, ActionDialog, AvatarCell, Badge, DataTableColumnHeader, DataTableManualPagination, DateCell, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, TextCell, Tooltip, TooltipContent, TooltipTrigger } from '@stackframe/stack-ui'; import type { ColumnDef, ColumnFiltersState, SortingState } from '@tanstack/react-table'; import type { LucideIcon } from 'lucide-react'; -import { ArrowDownCircle, ArrowUpCircle, Ban, CircleHelp, RefreshCcw, RotateCcw, Settings, ShoppingCart, Shuffle } from 'lucide-react'; +import { Ban, CircleHelp, RefreshCcw, RotateCcw, Settings, ShoppingCart, Shuffle } from 'lucide-react'; import { Link } from '../link'; import React from 'react'; @@ -25,12 +25,15 @@ type TransactionSummary = { customerId: string | null, detail: string, amountDisplay: string, + refundTarget: RefundTarget | null, + refunded: boolean, }; type EntryWithCustomer = Extract; type MoneyTransferEntry = Extract; type ProductGrantEntry = Extract; type ItemQuantityChangeEntry = Extract; +type RefundTarget = { type: 'subscription' | 'one-time-purchase', id: string }; function isEntryWithCustomer(entry: TransactionEntry): entry is EntryWithCustomer { return 'customer_type' in entry && 'customer_id' in entry; @@ -48,6 +51,20 @@ function isItemQuantityChangeEntry(entry: TransactionEntry): entry is ItemQuanti return entry.type === 'item_quantity_change'; } +function getRefundTarget(transaction: Transaction): RefundTarget | null { + if (transaction.type !== 'purchase') { + return null; + } + const productGrant = transaction.entries.find(isProductGrantEntry); + if (productGrant?.subscription_id) { + return { type: 'subscription', id: productGrant.subscription_id }; + } + if (productGrant?.one_time_purchase_id) { + return { type: 'one-time-purchase', id: productGrant.one_time_purchase_id }; + } + return null; +} + function deriveSourceType(transaction: Transaction): SourceType { if (transaction.entries.some(isItemQuantityChangeEntry)) return 'item_quantity_change'; const productGrant = transaction.entries.find(isProductGrantEntry); @@ -74,12 +91,6 @@ function formatTransactionTypeLabel(transactionType: TransactionType | null): Tr case 'manual-item-quantity-change': { return { label: 'Manual Item Quantity Change', Icon: Settings }; } - case 'upgrade': { - return { label: 'Upgrade', Icon: ArrowUpCircle }; - } - case 'downgrade': { - return { label: 'Downgrade', Icon: ArrowDownCircle }; - } case 'product-change': { return { label: 'Product Change', Icon: Shuffle }; } @@ -155,13 +166,15 @@ function describeDetail(transaction: Transaction, sourceType: SourceType): strin if (sourceType === 'item_quantity_change') { return 'Item quantity change'; } - return '—'; + return '-'; } function getTransactionSummary(transaction: Transaction): TransactionSummary { const sourceType = deriveSourceType(transaction); const customerEntry = transaction.entries.find(isEntryWithCustomer); const moneyTransferEntry = transaction.entries.find(isMoneyTransferEntry); + const refundTarget = getRefundTarget(transaction); + const refunded = transaction.adjusted_by.length > 0; return { sourceType, @@ -170,9 +183,56 @@ function getTransactionSummary(transaction: Transaction): TransactionSummary { customerId: customerEntry?.customer_id ?? null, detail: describeDetail(transaction, sourceType), amountDisplay: transaction.test_mode ? 'Test mode' : pickChargedAmountDisplay(moneyTransferEntry), + refundTarget, + refunded, }; } +function RefundActionCell({ transaction, refundTarget }: { transaction: Transaction, refundTarget: RefundTarget | null }) { + const app = useAdminApp(); + const [isDialogOpen, setIsDialogOpen] = React.useState(false); + const target = transaction.type === 'purchase' ? refundTarget : null; + const alreadyRefunded = transaction.adjusted_by.length > 0; + const productEntry = transaction.entries.find(isProductGrantEntry); + const canRefund = !!target && !transaction.test_mode && !alreadyRefunded && productEntry?.price_id; + + return ( + <> + {target ? ( + { + await app.refundTransaction(target); + setIsDialogOpen(false); + }, + }} + confirmText="Refunds cannot be undone and will revoke access to the purchased product." + > + {`Refund this ${target.type === 'subscription' ? 'subscription' : 'one-time purchase'} transaction?`} + + ) : null} + { + if (!target) return; + setIsDialogOpen(true); + }, + }]} + /> + + ); +} + type Filters = { cursor?: string, limit?: number, @@ -253,19 +313,43 @@ export function TransactionTable() { header: ({ column }) => , cell: ({ row }) => { const summary = summaryById.get(row.original.id); - return {summary?.detail ?? '—'}; + return ( + +
+ {summary?.detail ?? '—'} + {summary?.refunded ? ( + + Refunded + + ) : null} +
+
+ ); }, enableSorting: false, }, { id: 'created_at_millis', accessorFn: (transaction) => transaction.created_at_millis, - header: ({ column }) => , + header: ({ column }) => , cell: ({ row }) => ( -
{new Date(row.original.created_at_millis).toLocaleString()}
+ ), enableSorting: false, }, + { + id: 'actions', + cell: ({ row }) => { + const summary = summaryById.get(row.original.id); + return ( + + ); + }, + enableSorting: false, + }, ], [summaryById]); const onUpdate = async (options: { @@ -302,6 +386,7 @@ export function TransactionTable() { amount: true, detail: true, created_at_millis: true, + actions: true, }} defaultColumnFilters={[ { id: 'source_type', value: undefined }, diff --git a/apps/e2e/tests/backend/backend-helpers.ts b/apps/e2e/tests/backend/backend-helpers.ts index 002424d553..0c11e88e85 100644 --- a/apps/e2e/tests/backend/backend-helpers.ts +++ b/apps/e2e/tests/backend/backend-helpers.ts @@ -6,7 +6,7 @@ import { filterUndefined } from "@stackframe/stack-shared/dist/utils/objects"; import { wait } from "@stackframe/stack-shared/dist/utils/promises"; import { nicify } from "@stackframe/stack-shared/dist/utils/strings"; import * as jose from "jose"; -import { randomUUID } from "node:crypto"; +import { createHmac, randomUUID } from "node:crypto"; import { expect } from "vitest"; import { Context, Mailbox, NiceRequestInit, NiceResponse, STACK_BACKEND_BASE_URL, STACK_INTERNAL_PROJECT_ADMIN_KEY, STACK_INTERNAL_PROJECT_CLIENT_KEY, STACK_INTERNAL_PROJECT_ID, STACK_INTERNAL_PROJECT_SERVER_KEY, STACK_SVIX_SERVER_URL, generatedEmailSuffix, localRedirectUrl, niceFetch, updateCookiesFromResponse } from "../helpers"; import { localhostUrl, withPortPrefix } from "../helpers/ports"; @@ -1495,4 +1495,34 @@ export namespace Payments { code, }; } + + export async function sendStripeWebhook( + payload: unknown, + options?: { + invalidSignature?: boolean, + omitSignature?: boolean, + secret?: string, + } + ) { + const timestamp = Math.floor(Date.now() / 1000); + const headers: Record = { "content-type": "application/json" }; + if (!options?.omitSignature) { + let header: string; + if (options?.invalidSignature) { + header = `t=${timestamp},v1=dead`; + } else { + const hmac = createHmac("sha256", options?.secret ?? "mock_stripe_webhook_secret"); + hmac.update(`${timestamp}.${JSON.stringify(payload)}`); + const signature = hmac.digest("hex"); + header = `t=${timestamp},v1=${signature}`; + } + headers["stripe-signature"] = header; + } + return await niceBackendFetch("/api/latest/integrations/stripe/webhooks", { + method: "POST", + headers, + body: payload, + }); + } + } diff --git a/apps/e2e/tests/backend/endpoints/api/v1/internal/transactions-refund.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/internal/transactions-refund.test.ts new file mode 100644 index 0000000000..242ba04e5f --- /dev/null +++ b/apps/e2e/tests/backend/endpoints/api/v1/internal/transactions-refund.test.ts @@ -0,0 +1,290 @@ +import { randomUUID } from "node:crypto"; +import { expect } from "vitest"; +import { it } from "../../../../../helpers"; +import { Payments, Project, User, niceBackendFetch } from "../../../../backend-helpers"; + +function createDefaultPaymentsConfig(testMode: boolean | undefined) { + return { + payments: { + testMode: testMode ?? true, + products: { + "sub-product": { + displayName: "Sub Product", + customerType: "user", + serverOnly: false, + stackable: false, + prices: { + monthly: { USD: "1000", interval: [1, "month"] }, + }, + includedItems: {}, + }, + "otp-product": { + displayName: "One-Time Product", + customerType: "user", + serverOnly: false, + stackable: false, + prices: { + single: { USD: "5000" }, + }, + includedItems: {}, + }, + }, + items: {}, + }, + }; +} + +async function setupProjectWithPaymentsConfig(options: { testMode?: boolean } = {}) { + await Project.createAndSwitch(); + await Payments.setup(); + const config = createDefaultPaymentsConfig(options.testMode); + await Project.updateConfig(config); + return config; +} + +async function createPurchaseCode(options: { userId: string, productId: string }) { + const res = await niceBackendFetch("/api/latest/payments/purchases/create-purchase-url", { + method: "POST", + accessType: "client", + body: { + customer_type: "user", + customer_id: options.userId, + product_id: options.productId, + }, + }); + 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; +} + +async function createTestModeTransaction(productId: string, priceId: string) { + const { userId } = await User.create(); + const code = await createPurchaseCode({ userId, productId }); + const response = await niceBackendFetch("/api/latest/internal/payments/test-mode-purchase-session", { + accessType: "admin", + method: "POST", + body: { full_code: code, price_id: priceId, quantity: 1 }, + }); + expect(response.status).toBe(200); + const transactions = await niceBackendFetch("/api/latest/internal/payments/transactions", { + accessType: "admin", + }); + expect(transactions.status).toBe(200); + expect(transactions.body.transactions.length).toBeGreaterThan(0); + const transaction = transactions.body.transactions[0]; + return { transactionId: transaction.id, userId }; +} + +it("returns TestModePurchaseNonRefundable when refunding test mode one-time purchases", async () => { + await setupProjectWithPaymentsConfig(); + const { transactionId, userId } = await createTestModeTransaction("otp-product", "single"); + + const productsRes = await niceBackendFetch(`/api/v1/payments/products/user/${userId}`, { + accessType: "client", + }); + expect(productsRes.status).toBe(200); + expect(productsRes.body.items).toHaveLength(1); + expect(productsRes.body.items[0].id).toBe("otp-product"); + + const refundRes = await niceBackendFetch("/api/latest/internal/payments/transactions/refund", { + accessType: "admin", + method: "POST", + body: { type: "one-time-purchase", id: transactionId }, + }); + expect(refundRes).toMatchInlineSnapshot(` + NiceResponse { + "status": 400, + "body": { + "code": "TEST_MODE_PURCHASE_NON_REFUNDABLE", + "error": "Test mode purchases are not refundable.", + }, + "headers": Headers { + "x-stack-known-error": "TEST_MODE_PURCHASE_NON_REFUNDABLE", +