Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
b604a3a
transactions
BilalG1 Oct 29, 2025
3445951
small transaction fixes
BilalG1 Oct 29, 2025
aae139c
transaction table update
BilalG1 Oct 30, 2025
db16ac2
Merge branch 'dev' into payment-transactions
BilalG1 Oct 30, 2025
bb92c8e
remove log
BilalG1 Oct 30, 2025
5b1a8c5
fix lint err
BilalG1 Oct 30, 2025
ad79275
Merge dev into payment-transactions
N2D4 Oct 30, 2025
323e40a
wip
BilalG1 Oct 30, 2025
6a36d07
fix tests
BilalG1 Oct 30, 2025
47141ce
Merge remote-tracking branch 'origin/dev' into payment-transactions
BilalG1 Oct 30, 2025
b30fa8c
fix tests
BilalG1 Oct 30, 2025
ba0eaa2
rm extra test
BilalG1 Oct 30, 2025
42e3d83
rm unused
BilalG1 Oct 30, 2025
bfa5685
remove unnecessary
BilalG1 Oct 30, 2025
bd9d850
remove unneeded
BilalG1 Oct 30, 2025
dc4b53b
Merge dev into payment-transactions
N2D4 Oct 31, 2025
11ddac1
Merge dev into payment-transactions
N2D4 Nov 4, 2025
ec0c18e
Merge dev into payment-transactions
N2D4 Nov 5, 2025
6fce497
Merge remote-tracking branch 'origin/dev' into payment-transactions
BilalG1 Nov 6, 2025
6914e5a
Merge dev into payment-transactions
N2D4 Nov 6, 2025
420a508
Merge dev into payment-transactions
N2D4 Nov 7, 2025
9d6d035
Merge dev into payment-transactions
N2D4 Nov 10, 2025
b3e2670
Merge dev into payment-transactions
N2D4 Nov 11, 2025
740b941
add more transaction tests
BilalG1 Nov 11, 2025
4693a82
Merge branch 'dev' into payment-transactions
BilalG1 Nov 11, 2025
d1953ce
Merge dev into payment-transactions
N2D4 Nov 13, 2025
e77f688
Merge dev into payment-transactions
N2D4 Nov 14, 2025
c914d11
Merge dev into payment-transactions
N2D4 Nov 18, 2025
19dfcff
subscription renewal transactions (#1005)
BilalG1 Nov 18, 2025
31a924f
webhook fix
BilalG1 Nov 18, 2025
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,10 @@ 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);
}
}
}

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,
},
};
},
});
Loading
Loading