Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
76 commits
Select commit Hold shift + click to select a range
a30a864
type fix
BilalG1 Aug 20, 2025
d6397fa
custom customer type
BilalG1 Aug 20, 2025
5e5c0ae
merge dev
BilalG1 Aug 20, 2025
d0e4e09
small fixes
BilalG1 Aug 20, 2025
1018ee5
fix test
BilalG1 Aug 20, 2025
553ffe0
fix issues
BilalG1 Aug 20, 2025
7e95b6b
revert import changes
BilalG1 Aug 20, 2025
da6f000
fix test
BilalG1 Aug 20, 2025
4b79dd1
offer and item pages, edits, deletes
BilalG1 Aug 20, 2025
4c546b8
fix lint
BilalG1 Aug 20, 2025
c22bf3c
small fixes
BilalG1 Aug 21, 2025
2b19be0
fix copy
BilalG1 Aug 21, 2025
03c06a2
payments test mode
BilalG1 Aug 21, 2025
19d1d52
revoke codes
BilalG1 Aug 21, 2025
582f876
fix test
BilalG1 Aug 21, 2025
8626012
stackable purchases
BilalG1 Aug 21, 2025
d5f0dd4
server only offers
BilalG1 Aug 21, 2025
82ff799
seed extra-admins, small fixes
BilalG1 Aug 21, 2025
eb7ee91
wip
BilalG1 Aug 22, 2025
1a3b465
updated schema
N2D4 Aug 22, 2025
40ff247
Include by default price
N2D4 Aug 22, 2025
4645d09
Combine subscriptions from DB and include-by-default
N2D4 Aug 22, 2025
3488427
wip updated schema
BilalG1 Aug 23, 2025
101c98a
schema fixes, ledger transactions
BilalG1 Aug 25, 2025
b27c7c8
merge dev
BilalG1 Aug 25, 2025
e537f6b
type fixes
BilalG1 Aug 25, 2025
2198b63
Separate Stripe account ID from schema
N2D4 Aug 25, 2025
9a5ac32
Merge branch 'payments-tx-ledger-algo' into payments-separate-stripe-…
N2D4 Aug 25, 2025
54bdf9a
Update AGENTS.md
N2D4 Aug 25, 2025
d10452d
Make CLAUDE.md an alias
N2D4 Aug 25, 2025
5e2b22a
fix tests and getSubscriptions
BilalG1 Aug 26, 2025
90c0b24
Merge remote-tracking branch 'origin/payments-separate-stripe-account…
BilalG1 Aug 26, 2025
dfdd231
type fix
BilalG1 Aug 26, 2025
8545274
fix stripe account info
BilalG1 Aug 26, 2025
59d375b
fix tests
BilalG1 Aug 26, 2025
4b7f823
fix tests
BilalG1 Aug 26, 2025
ee93553
fix tests
BilalG1 Aug 26, 2025
6e1a689
Merge dev into payments-tx-ledger-algo
N2D4 Aug 26, 2025
0420aa3
fix tests
BilalG1 Aug 26, 2025
f01efbb
Merge branch 'payments-tx-ledger-algo' of https://github.com/stack-au…
BilalG1 Aug 26, 2025
b36ed7e
fix createCheckoutUrl
BilalG1 Aug 26, 2025
30814f0
one time payments
BilalG1 Aug 26, 2025
cbcc983
remove export
BilalG1 Aug 26, 2025
157c024
payments fixes
BilalG1 Aug 27, 2025
7b2ac57
validate-code extra info
BilalG1 Aug 27, 2025
11bf97e
merge
BilalG1 Aug 27, 2025
c878b3f
Merge branch 'dev' into payments-tx-ledger-algo
N2D4 Aug 27, 2025
b4c0d83
one-time tests
BilalG1 Aug 27, 2025
dc37b10
fix test
BilalG1 Aug 27, 2025
2645635
fix conflicts
BilalG1 Aug 27, 2025
27a4676
schema
BilalG1 Aug 27, 2025
ac9d275
Merge remote-tracking branch 'origin/payments-tx-ledger-algo' into on…
BilalG1 Aug 27, 2025
80d5cde
one time fixes
BilalG1 Aug 27, 2025
6228f15
merge dev
BilalG1 Aug 27, 2025
e549167
remove as any
BilalG1 Aug 27, 2025
bc3cd06
Merge dev into one-time-payments
N2D4 Aug 28, 2025
0598572
merge dev
BilalG1 Sep 2, 2025
82338d5
Merge branch 'dev' into one-time-payments
BilalG1 Sep 2, 2025
88c6fc9
Merge dev into one-time-payments
N2D4 Sep 3, 2025
5e0765e
Merge dev into one-time-payments
N2D4 Sep 4, 2025
52fe8eb
Merge dev into one-time-payments
N2D4 Sep 5, 2025
0fe607f
Merge dev into one-time-payments
N2D4 Sep 6, 2025
04f9171
Merge dev into one-time-payments
N2D4 Sep 9, 2025
1f3f20e
Merge dev into one-time-payments
N2D4 Sep 10, 2025
0c58329
Merge dev into one-time-payments
N2D4 Sep 11, 2025
2ff6beb
one time payment fixes
BilalG1 Sep 11, 2025
e49c560
Merge remote-tracking branch 'origin/dev' into one-time-payments
BilalG1 Sep 11, 2025
f276960
migration fix
BilalG1 Sep 11, 2025
e694385
safe migration
BilalG1 Sep 11, 2025
8180ced
fix schema
BilalG1 Sep 11, 2025
7678d50
add otp double webhook handling and test
BilalG1 Sep 12, 2025
4446799
Merge branch 'dev' into one-time-payments
BilalG1 Sep 12, 2025
c1fd6ea
stripe proxy and more tests
BilalG1 Sep 12, 2025
726ebe2
proxy stripe
BilalG1 Sep 12, 2025
2d3cb35
Merge branch 'dev' into one-time-payments
BilalG1 Sep 12, 2025
834fb3c
Merge branch 'dev' into one-time-payments
BilalG1 Sep 12, 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,21 @@
-- CreateEnum
ALTER TYPE "SubscriptionCreationSource" RENAME TO "PurchaseCreationSource";

-- CreateTable
CREATE TABLE "OneTimePurchase" (
"id" UUID NOT NULL,
"tenancyId" UUID NOT NULL,
"customerId" TEXT NOT NULL,
"customerType" "CustomerType" NOT NULL,
"offerId" TEXT,
"offer" JSONB NOT NULL,
"quantity" INTEGER NOT NULL,
"stripePaymentIntentId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"creationSource" "PurchaseCreationSource" NOT NULL,

CONSTRAINT "OneTimePurchase_pkey" PRIMARY KEY ("tenancyId","id")
);

-- CreateIndex
CREATE UNIQUE INDEX "OneTimePurchase_tenancyId_stripePaymentIntentId_key" ON "OneTimePurchase"("tenancyId", "stripePaymentIntentId");
20 changes: 18 additions & 2 deletions apps/backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -753,7 +753,7 @@ enum SubscriptionStatus {
unpaid
}

enum SubscriptionCreationSource {
enum PurchaseCreationSource {
PURCHASE_PAGE
TEST_MODE
}
Expand All @@ -773,7 +773,7 @@ model Subscription {
currentPeriodStart DateTime
cancelAtPeriodEnd Boolean

creationSource SubscriptionCreationSource
creationSource PurchaseCreationSource
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

Expand All @@ -795,6 +795,22 @@ model ItemQuantityChange {
@@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
stripePaymentIntentId String?
createdAt DateTime @default(now())
creationSource PurchaseCreationSource

@@id([tenancyId, id])
@@unique([tenancyId, stripePaymentIntentId])
}

model DataVaultEntry {
id String @default(uuid()) @db.Uuid
tenancyId String @db.Uuid
Expand Down
107 changes: 84 additions & 23 deletions apps/backend/src/app/api/latest/integrations/stripe/webhooks/route.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
import { getStackStripe, syncStripeSubscriptions } from "@/lib/stripe";
import { getStackStripe, getStripeForAccount, syncStripeSubscriptions } from "@/lib/stripe";
import { getTenancy } from "@/lib/tenancies";
import { getPrismaClientForTenancy } from "@/prisma-client";
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
import { yupMixed, yupNumber, yupObject, yupString, yupTuple } from "@stackframe/stack-shared/dist/schema-fields";
import { typedIncludes } from '@stackframe/stack-shared/dist/utils/arrays';
import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env";
import { StackAssertionError, captureError } from "@stackframe/stack-shared/dist/utils/errors";
import { StackAssertionError, StatusError, captureError } from "@stackframe/stack-shared/dist/utils/errors";
import { typedToUppercase } from "@stackframe/stack-shared/dist/utils/strings";
import Stripe from "stripe";

const subscriptionChangedEvents = [
Expand Down Expand Up @@ -30,6 +34,74 @@ const isSubscriptionChangedEvent = (event: Stripe.Event): event is Stripe.Event
return subscriptionChangedEvents.includes(event.type as any);
};

async function processStripeWebhookEvent(event: Stripe.Event): Promise<void> {
const mockData = (event.data.object as any).stack_stripe_mock_data;
if (event.type === "payment_intent.succeeded" && event.data.object.metadata.purchaseKind === "ONE_TIME") {
const metadata = event.data.object.metadata;
const accountId = event.account;
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;
if (!tenancyId) {
throw new StackAssertionError("Stripe account metadata missing tenancyId", { event });
}
const tenancy = await getTenancy(tenancyId);
if (!tenancy) {
throw new StackAssertionError("Tenancy not found", { event });
}
const prisma = await getPrismaClientForTenancy(tenancy);
const offer = JSON.parse(metadata.offer || "{}");
const qty = Math.max(1, Number(metadata.purchaseQuantity || 1));
const stripePaymentIntentId = event.data.object.id;
if (!metadata.customerId || !metadata.customerType) {
throw new StackAssertionError("Missing customer metadata for one-time purchase", { event });
}
if (!typedIncludes(["user", "team", "custom"] as const, metadata.customerType)) {
throw new StackAssertionError("Invalid customer type for one-time purchase", { event });
}
await prisma.oneTimePurchase.upsert({
where: {
tenancyId_stripePaymentIntentId: {
tenancyId: tenancy.id,
stripePaymentIntentId,
},
},
create: {
tenancyId: tenancy.id,
customerId: metadata.customerId,
customerType: typedToUppercase(metadata.customerType),
offerId: metadata.offerId || null,
stripePaymentIntentId,
offer,
quantity: qty,
creationSource: "PURCHASE_PAGE",
},
update: {
offerId: metadata.offerId || null,
offer,
quantity: qty,
}
});
}

if (isSubscriptionChangedEvent(event)) {
const accountId = event.account;
const customerId = event.data.object.customer;
if (!accountId) {
throw new StackAssertionError("Stripe webhook account id missing", { event });
}
if (typeof customerId !== 'string') {
throw new StackAssertionError("Stripe webhook bad customer id", { event });
}
const stripe = await getStripeForAccount({ accountId }, mockData);
await syncStripeSubscriptions(stripe, accountId, customerId);
}
}

export const POST = createSmartRouteHandler({
metadata: {
hidden: true,
Expand All @@ -47,38 +119,27 @@ export const POST = createSmartRouteHandler({
body: yupMixed().defined(),
}),
handler: async (req, fullReq) => {
const stripe = getStackStripe();
let event: Stripe.Event;
try {
const stripe = getStackStripe();
const signature = req.headers["stripe-signature"][0];
if (!signature) {
throw new StackAssertionError("Missing stripe-signature header");
}

const textBody = new TextDecoder().decode(fullReq.bodyBuffer);
const event = stripe.webhooks.constructEvent(
event = stripe.webhooks.constructEvent(
textBody,
signature,
getEnvVariable("STACK_STRIPE_WEBHOOK_SECRET"),
);
} catch {
throw new StatusError(400, "Invalid stripe-signature header");
}

if (event.type === "account.updated") {
if (!event.account) {
throw new StackAssertionError("Stripe webhook account id missing", { event });
}
} else if (isSubscriptionChangedEvent(event)) {
const accountId = event.account;
const customerId = (event.data.object as any).customer;
if (!accountId) {
throw new StackAssertionError("Stripe webhook account id missing", { event });
}
if (typeof customerId !== 'string') {
throw new StackAssertionError("Stripe webhook bad customer id", { event });
}
await syncStripeSubscriptions(accountId, customerId);
}
try {
await processStripeWebhookEvent(event);
} catch (error) {
captureError("stripe-webhook-receiver", error);
throw error;
}

return {
statusCode: 200,
bodyType: "json",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
import { purchaseUrlVerificationCodeHandler } from "@/app/api/latest/payments/purchases/verification-code-handler";
import { isActiveSubscription, validatePurchaseSession } from "@/lib/payments";
import { validatePurchaseSession } from "@/lib/payments";
import { getStripeForAccount } from "@/lib/stripe";
import { getPrismaClientForTenancy, retryTransaction } from "@/prisma-client";
import { getPrismaClientForTenancy } from "@/prisma-client";
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
import { SubscriptionCreationSource, SubscriptionStatus } from "@prisma/client";
import { SubscriptionStatus } from "@prisma/client";
import { adaptSchema, adminAuthTypeSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
import { addInterval } from "@stackframe/stack-shared/dist/utils/dates";
import { StatusError } from "@stackframe/stack-shared/dist/utils/errors";
import { typedKeys } from "@stackframe/stack-shared/dist/utils/objects";
import { StackAssertionError, StatusError } from "@stackframe/stack-shared/dist/utils/errors";
import { typedToUppercase } from "@stackframe/stack-shared/dist/utils/strings";

export const POST = createSmartRouteHandler({
Expand Down Expand Up @@ -37,65 +36,50 @@ export const POST = createSmartRouteHandler({
throw new StatusError(400, "Tenancy id does not match value from code data");
}
const prisma = await getPrismaClientForTenancy(auth.tenancy);
const { selectedPrice, groupId, subscriptions } = await validatePurchaseSession({

const { selectedPrice, conflictingGroupSubscriptions } = await validatePurchaseSession({
prisma,
tenancy: auth.tenancy,
codeData: data,
priceId: price_id,
quantity,
});
if (groupId) {
for (const subscription of subscriptions) {
if (
subscription.id &&
subscription.offerId &&
subscription.offer.groupId === groupId &&
isActiveSubscription(subscription) &&
subscription.offer.prices !== "include-by-default" &&
(!data.offer.isAddOnTo || !typedKeys(data.offer.isAddOnTo).includes(subscription.offerId))
) {
if (!selectedPrice?.interval) {
continue;
}
if (subscription.stripeSubscriptionId) {
const stripe = await getStripeForAccount({ tenancy: auth.tenancy });
await stripe.subscriptions.cancel(subscription.stripeSubscriptionId);
}
await retryTransaction(prisma, async (tx) => {
if (!subscription.stripeSubscriptionId && subscription.id) {
await tx.subscription.update({
where: {
tenancyId_id: {
tenancyId: auth.tenancy.id,
id: subscription.id,
},
},
data: {
status: SubscriptionStatus.canceled,
},
});
}
await tx.subscription.create({
data: {
if (!selectedPrice) {
throw new StackAssertionError("Price not resolved for test mode purchase session");
}

if (!selectedPrice.interval) {
await prisma.oneTimePurchase.create({
data: {
tenancyId: auth.tenancy.id,
customerId: data.customerId,
customerType: typedToUppercase(data.offer.customerType),
offerId: data.offerId,
offer: data.offer,
quantity,
creationSource: "TEST_MODE",
},
});
} else {
// Cancel conflicting subscriptions for TEST_MODE as well, then create new TEST_MODE subscription
if (conflictingGroupSubscriptions.length > 0) {
const conflicting = conflictingGroupSubscriptions[0];
if (conflicting.stripeSubscriptionId) {
const stripe = await getStripeForAccount({ tenancy: auth.tenancy });
await stripe.subscriptions.cancel(conflicting.stripeSubscriptionId);
} else if (conflicting.id) {
await prisma.subscription.update({
where: {
tenancyId_id: {
tenancyId: auth.tenancy.id,
customerId: data.customerId,
customerType: typedToUppercase(data.offer.customerType),
status: SubscriptionStatus.active,
offerId: data.offerId,
offer: data.offer,
quantity,
currentPeriodStart: new Date(),
currentPeriodEnd: addInterval(new Date(), selectedPrice.interval!),
cancelAtPeriodEnd: false,
creationSource: SubscriptionCreationSource.TEST_MODE,
id: conflicting.id,
},
});
},
data: { status: SubscriptionStatus.canceled },
});
}
}
}

if (selectedPrice?.interval) {
await prisma.subscription.create({
data: {
tenancyId: auth.tenancy.id,
Expand All @@ -106,9 +90,9 @@ export const POST = createSmartRouteHandler({
offer: data.offer,
quantity,
currentPeriodStart: new Date(),
currentPeriodEnd: addInterval(new Date(), selectedPrice.interval),
currentPeriodEnd: addInterval(new Date(), selectedPrice.interval!),
cancelAtPeriodEnd: false,
creationSource: SubscriptionCreationSource.TEST_MODE,
creationSource: "TEST_MODE",
},
});
}
Expand Down
Loading