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

CONSTRAINT "OneTimePurchase_pkey" PRIMARY KEY ("tenancyId","id")
Comment thread
BilalG1 marked this conversation as resolved.
);
18 changes: 16 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
}
Comment thread
BilalG1 marked this conversation as resolved.
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,20 @@ 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
createdAt DateTime @default(now())
creationSource PurchaseCreationSource

@@id([tenancyId, id])
}
Comment thread
BilalG1 marked this conversation as resolved.

model DataVaultEntry {
id String @default(uuid()) @db.Uuid
tenancyId String @db.Uuid
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
import { getStackStripe, 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 { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env";
import { StackAssertionError, captureError } from "@stackframe/stack-shared/dist/utils/errors";
import { typedToUppercase } from "@stackframe/stack-shared/dist/utils/strings";
import { typedIncludes } from '@stackframe/stack-shared/dist/utils/arrays';
import Stripe from "stripe";

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

async function processStripeWebhookEvent(event: Stripe.Event): Promise<void> {
if (event.type === "payment_intent.succeeded") {
const object = event.data.object as any;
const metadata = object?.metadata ?? {};
if (metadata.purchaseKind === "ONE_TIME") {
const accountId = event.account;
if (!accountId) {
throw new StackAssertionError("Stripe webhook account id missing", { event });
}
const stripe = getStackStripe();
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 || "{}");
Comment thread
BilalG1 marked this conversation as resolved.
Outdated
Comment thread
BilalG1 marked this conversation as resolved.
Outdated
const customerIdMeta: string | undefined = metadata.customerId;
const customerTypeMeta: string | undefined = metadata.customerType;
Comment thread
BilalG1 marked this conversation as resolved.
Outdated
const qty = Math.max(1, Number(metadata.purchaseQuantity || 1));
Comment thread
BilalG1 marked this conversation as resolved.
Outdated
if (!customerIdMeta || !customerTypeMeta) {
throw new StackAssertionError("Missing customer metadata for one-time purchase", { event });
}
if (!typedIncludes(["user", "team", "custom"] as const, customerTypeMeta)) {
throw new StackAssertionError("Invalid customer type for one-time purchase", { event });
}
await prisma.oneTimePurchase.create({
data: {
tenancyId: tenancy.id,
customerId: customerIdMeta,
customerType: typedToUppercase(customerTypeMeta),
offerId: metadata.offerId || null,
offer,
quantity: qty,
creationSource: "PURCHASE_PAGE",
},
});
return;
}
}

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);
return;
}
}

export const POST = createSmartRouteHandler({
metadata: {
hidden: true,
Expand Down Expand Up @@ -60,22 +123,7 @@ export const POST = createSmartRouteHandler({
signature,
getEnvVariable("STACK_STRIPE_WEBHOOK_SECRET"),
);

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);
}
await processStripeWebhookEvent(event);
} catch (error) {
captureError("stripe-webhook-receiver", error);
}
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