Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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;

Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "OneTimePurchase" ADD COLUMN "refundedAt" TIMESTAMP(3);

Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "Subscription" ADD COLUMN "refundedAt" TIMESTAMP(3);

21 changes: 21 additions & 0 deletions apps/backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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])
}
Expand Down Expand Up @@ -819,6 +823,7 @@ model OneTimePurchase {
quantity Int
stripePaymentIntentId String?
createdAt DateTime @default(now())
refundedAt DateTime?
creationSource PurchaseCreationSource

@@id([tenancyId, id])
Expand Down Expand Up @@ -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])
}
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -88,7 +88,6 @@ async function processStripeWebhookEvent(event: Stripe.Event): Promise<void> {
}
});
}

if (isSubscriptionChangedEvent(event)) {
const accountId = event.account;
const customerId = event.data.object.customer;
Expand All @@ -100,6 +99,13 @@ async function processStripeWebhookEvent(event: Stripe.Event): Promise<void> {
}
const stripe = await getStripeForAccount({ accountId }, mockData);
await syncStripeSubscriptions(stripe, accountId, customerId);

if (event.type == "invoice.payment_succeeded") {
await handleStripeInvoicePaid(stripe, accountId, event.data.object);
}
}
if (event.type === "refund.created") {
const refund = event.data.object;
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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,
},
};
},
});
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -40,17 +45,19 @@ 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 <T extends "subscription" | "itemQuantityChange" | "oneTimePurchase">(
const paginateWhere = async <T extends "subscription" | "itemQuantityChange" | "oneTimePurchase" | "subscriptionInvoice">(
table: T,
cursorId?: string
): Promise<
T extends "subscription"
? 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;
Expand All @@ -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 {
Expand All @@ -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 }];
Expand All @@ -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,
Expand All @@ -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;
Expand All @@ -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 {
Expand All @@ -169,4 +211,3 @@ export const GET = createSmartRouteHandler({
};
},
});

Loading
Loading