Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
61 commits
Select commit Hold shift + click to select a range
1798e52
New payments
N2D4 Jul 22, 2025
d37c619
Merge branch 'dev' into payments-release
N2D4 Jul 22, 2025
83fdba5
fixes
N2D4 Jul 22, 2025
020336e
More changes
N2D4 Jul 22, 2025
9e589d9
Fixes
N2D4 Jul 23, 2025
fef654e
Merge dev into payments-release
N2D4 Jul 23, 2025
cbe2a08
Merge dev into payments-release
N2D4 Jul 24, 2025
de48a50
Merge dev into payments-release
N2D4 Jul 25, 2025
3a021cc
Fixes
N2D4 Jul 25, 2025
0736fd9
Merge branch 'dev' into payments-release
N2D4 Jul 25, 2025
cad3b39
Fixes
N2D4 Jul 25, 2025
57177be
TypeScript mess
N2D4 Jul 25, 2025
6c17ed7
more
N2D4 Jul 26, 2025
b23aee5
more
N2D4 Jul 26, 2025
5c005de
fixes
N2D4 Jul 26, 2025
a7fb8ae
fix
N2D4 Jul 28, 2025
3d1046f
more fixes
N2D4 Jul 28, 2025
5332b03
fixes
N2D4 Jul 28, 2025
a70f59d
more fixes
N2D4 Jul 28, 2025
805c396
More fixes
N2D4 Jul 28, 2025
3ef091c
Merge branch 'dev' into payments-release
N2D4 Jul 29, 2025
00116c9
more fixes
N2D4 Jul 29, 2025
e027292
Merge branch 'dev' into payments-release
N2D4 Jul 29, 2025
701987a
more
N2D4 Jul 29, 2025
7c0d78c
Merge branch 'dev' into payments-release
N2D4 Jul 29, 2025
ebd6e86
More parts
N2D4 Jul 29, 2025
5686372
Apply suggestion from @coderabbitai[bot]
N2D4 Jul 29, 2025
4d79129
Merge dev into payments-release
N2D4 Jul 30, 2025
77a6358
Merge dev into payments-release
N2D4 Jul 31, 2025
1ff14ff
Merge dev into payments-release
N2D4 Aug 1, 2025
1c75fed
Merge remote-tracking branch 'origin/dev' into payments-release
BilalG1 Aug 1, 2025
1119ca2
feat: Add core payment schema and backend foundation
BilalG1 Aug 1, 2025
897b1f5
feat: Add backend payment APIs and Stripe integration
BilalG1 Aug 1, 2025
bcb6c11
feat: Add dashboard UI and frontend payment components
BilalG1 Aug 1, 2025
1b07baf
add mock stripe key to env
BilalG1 Aug 2, 2025
772fac5
Merge dev into payments-release
N2D4 Aug 2, 2025
ac574f0
Merge dev into payments-release
N2D4 Aug 3, 2025
80271ab
Merge dev into payments-release
N2D4 Aug 5, 2025
effebd7
payment tests, account status, smartRoutes
BilalG1 Aug 5, 2025
d526863
optional stripe api key
BilalG1 Aug 5, 2025
97f8d98
update db schema and migrations
BilalG1 Aug 5, 2025
14011ed
remove unused
BilalG1 Aug 5, 2025
ec0382a
fix stripe webhook responses
BilalG1 Aug 5, 2025
7f701d3
fix stripe response status
BilalG1 Aug 5, 2025
fc5cbe8
Merge branch 'payments-release' into payments-pr1-foundation
BilalG1 Aug 5, 2025
4c0adc6
Merge branch 'payments-pr1-foundation' into payments-pr2-apis
BilalG1 Aug 5, 2025
bdef395
Merge branch 'payments-pr2-apis' into payments-pr3-ui
BilalG1 Aug 5, 2025
2fb01c7
Merge branch 'payments-pr3-ui' into payments-pr4-accounts
BilalG1 Aug 5, 2025
45ca0c1
remove old migration
BilalG1 Aug 5, 2025
3956d0e
remove unused
BilalG1 Aug 5, 2025
192d75a
fix pr1 issues
BilalG1 Aug 8, 2025
329ac49
pr3 fixes
BilalG1 Aug 8, 2025
c66ef0a
pr-4 fixes
BilalG1 Aug 8, 2025
9045910
fix price input
BilalG1 Aug 8, 2025
a1d22f9
refactored dev enabled
BilalG1 Aug 8, 2025
bcfc7c4
async rsc
BilalG1 Aug 8, 2025
c3e7358
safe json parse
BilalG1 Aug 8, 2025
f728b71
Merge branch 'dev' into payments-pr4-accounts
BilalG1 Aug 8, 2025
aa0535d
fix flaky test
BilalG1 Aug 8, 2025
972c248
remove unused
BilalG1 Aug 8, 2025
f664d41
Merge branch 'dev' into payments-pr4-accounts
BilalG1 Aug 8, 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
2 changes: 2 additions & 0 deletions apps/backend/.env
Original file line number Diff line number Diff line change
Expand Up @@ -66,3 +66,5 @@ OTEL_EXPORTER_OTLP_ENDPOINT=# enter the OpenTelemetry endpoint here. Optional, d
STACK_INTEGRATION_CLIENTS_CONFIG=# a list of oidc-provider clients for integrations. If not provided, disables integrations
STACK_FREESTYLE_API_KEY=# enter your freestyle.sh api key
STACK_OPENAI_API_KEY=# enter your openai api key
STACK_STRIPE_SECRET_KEY=# enter your stripe api key
STACK_STRIPE_WEBHOOK_SECRET=# enter your stripe webhook secret
2 changes: 2 additions & 0 deletions apps/backend/.env.development
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ STACK_INTEGRATION_CLIENTS_CONFIG=[{"client_id": "neon-local", "client_secret": "
CRON_SECRET=mock_cron_secret
STACK_FREESTYLE_API_KEY=mock_stack_freestyle_key
STACK_OPENAI_API_KEY=mock_openai_api_key
STACK_STRIPE_SECRET_KEY=sk_test_mockstripekey
STACK_STRIPE_WEBHOOK_SECRET=mock_stripe_webhook_secret

# S3 Configuration for local development using s3mock
STACK_S3_ENDPOINT=http://localhost:8121
Expand Down
1 change: 1 addition & 0 deletions apps/backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@
"react-dom": "19.0.0",
"semver": "^7.6.3",
"sharp": "^0.32.6",
"stripe": "^18.3.0",
"svix": "^1.25.0",
"vite": "^6.1.0",
"yaml": "^2.4.5",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
-- CreateEnum
CREATE TYPE "CustomerType" AS ENUM ('USER', 'TEAM');

-- CreateEnum
CREATE TYPE "SubscriptionStatus" AS ENUM ('active', 'trialing', 'canceled', 'paused', 'incomplete', 'incomplete_expired', 'past_due', 'unpaid');

-- AlterEnum
ALTER TYPE "VerificationCodeType" ADD VALUE 'PURCHASE_URL';

-- CreateTable
CREATE TABLE "Subscription" (
"id" UUID NOT NULL,
"tenancyId" UUID NOT NULL,
"customerId" UUID NOT NULL,
"customerType" "CustomerType" NOT NULL,
"offer" JSONB NOT NULL,
"stripeSubscriptionId" TEXT NOT NULL,
"status" "SubscriptionStatus" NOT NULL,
"currentPeriodEnd" TIMESTAMP(3) NOT NULL,
"currentPeriodStart" TIMESTAMP(3) NOT NULL,
"cancelAtPeriodEnd" BOOLEAN NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,

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

-- CreateIndex
CREATE UNIQUE INDEX "Subscription_tenancyId_stripeSubscriptionId_key" ON "Subscription"("tenancyId", "stripeSubscriptionId");
37 changes: 37 additions & 0 deletions apps/backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -475,6 +475,7 @@ enum VerificationCodeType {
PASSKEY_REGISTRATION_CHALLENGE
PASSKEY_AUTHENTICATION_CHALLENGE
INTEGRATION_PROJECT_TRANSFER
PURCHASE_URL
}

//#region API keys
Expand Down Expand Up @@ -707,3 +708,39 @@ model ThreadMessage {

@@id([tenancyId, id])
}

enum CustomerType {
USER
TEAM
}

enum SubscriptionStatus {
active
trialing
canceled
paused
incomplete
incomplete_expired
past_due
unpaid
}

model Subscription {
id String @default(uuid()) @db.Uuid
tenancyId String @db.Uuid
customerId String @db.Uuid
customerType CustomerType
offer Json

stripeSubscriptionId String
status SubscriptionStatus
currentPeriodEnd DateTime
currentPeriodStart DateTime
cancelAtPeriodEnd Boolean

createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

@@id([tenancyId, id])
@@unique([tenancyId, stripeSubscriptionId])
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { getStackStripe, syncStripeAccountStatus, syncStripeSubscriptions } from "@/lib/stripe";
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 Stripe from "stripe";

const subscriptionChangedEvents = [
"checkout.session.completed",
"customer.subscription.created",
"customer.subscription.updated",
"customer.subscription.deleted",
"customer.subscription.paused",
"customer.subscription.resumed",
"customer.subscription.pending_update_applied",
"customer.subscription.pending_update_expired",
"customer.subscription.trial_will_end",
"invoice.paid",
"invoice.payment_failed",
"invoice.payment_action_required",
"invoice.upcoming",
"invoice.marked_uncollectible",
"invoice.payment_succeeded",
"payment_intent.succeeded",
"payment_intent.payment_failed",
"payment_intent.canceled",
] as const satisfies Stripe.Event.Type[];

const isSubscriptionChangedEvent = (event: Stripe.Event): event is Stripe.Event & { type: (typeof subscriptionChangedEvents)[number] } => {
return subscriptionChangedEvents.includes(event.type as any);
};

export const POST = createSmartRouteHandler({
metadata: {
hidden: true,
},
request: yupObject({
headers: yupObject({
"stripe-signature": yupTuple([yupString().defined()]).defined(),
}).defined(),
body: yupMixed().optional(),
method: yupString().oneOf(["POST"]).defined(),
}),
response: yupObject({
statusCode: yupNumber().oneOf([200]).defined(),
bodyType: yupString().oneOf(["json"]).defined(),
body: yupMixed().defined(),
}),
handler: async (req, fullReq) => {
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(
textBody,
signature,
getEnvVariable("STACK_STRIPE_WEBHOOK_SECRET"),
);

if (event.type === "account.updated") {
if (!event.account) {
throw new StackAssertionError("Stripe webhook account id missing", { event });
}
await syncStripeAccountStatus(event.account);
} 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);
}
} catch (error) {
captureError("stripe-webhook-receiver", error);
}
return {
statusCode: 200,
bodyType: "json",
body: { received: true }
};
},
});
67 changes: 67 additions & 0 deletions apps/backend/src/app/api/latest/internal/payments/setup/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { overrideEnvironmentConfigOverride } from "@/lib/config";
import { getStackStripe } from "@/lib/stripe";
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
import { adaptSchema, adminAuthTypeSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env";

export const POST = createSmartRouteHandler({
metadata: {
hidden: true,
},
request: yupObject({
auth: yupObject({
type: adminAuthTypeSchema.defined(),
project: adaptSchema.defined(),
tenancy: adaptSchema.defined(),
}).defined(),
}),
response: yupObject({
statusCode: yupNumber().oneOf([200]).defined(),
bodyType: yupString().oneOf(["json"]).defined(),
body: yupObject({
url: yupString().defined(),
}).defined(),
}),
handler: async ({ auth }) => {
const stripe = getStackStripe();
let stripeAccountId = auth.tenancy.config.payments.stripeAccountId;
const returnToUrl = new URL(`/projects/${auth.project.id}/payments`, getEnvVariable("NEXT_PUBLIC_STACK_DASHBOARD_URL")).toString();

if (!stripeAccountId) {
const account = await stripe.accounts.create({
controller: {
stripe_dashboard: { type: "none" },
},
capabilities: {
card_payments: { requested: true },
transfers: { requested: true },
},
country: "US",
metadata: {
tenancyId: auth.tenancy.id,
}
});
stripeAccountId = account.id;
await overrideEnvironmentConfigOverride({
projectId: auth.project.id,
branchId: auth.tenancy.branchId,
environmentConfigOverrideOverride: {
[`payments.stripeAccountId`]: stripeAccountId,
},
});
}

const accountLink = await stripe.accountLinks.create({
account: stripeAccountId,
refresh_url: returnToUrl,
return_url: returnToUrl,
type: "account_onboarding",
});

return {
statusCode: 200,
bodyType: "json",
body: { url: accountLink.url },
};
},
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { getStackStripe } from "@/lib/stripe";
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
import { adaptSchema, adminAuthTypeSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
import { StatusError } from "@stackframe/stack-shared/dist/utils/errors";

export const POST = createSmartRouteHandler({
metadata: {
hidden: true,
},
request: yupObject({
auth: yupObject({
type: adminAuthTypeSchema.defined(),
project: adaptSchema.defined(),
tenancy: adaptSchema.defined(),
}).defined(),
}),
response: yupObject({
statusCode: yupNumber().oneOf([200]).defined(),
bodyType: yupString().oneOf(["json"]).defined(),
body: yupObject({
client_secret: yupString().defined(),
}).defined(),
}),
handler: async ({ auth }) => {
const stripe = getStackStripe();
if (!auth.tenancy.config.payments.stripeAccountId) {
throw new StatusError(400, "Stripe account ID is not set");
}

const accountSession = await stripe.accountSessions.create({
account: auth.tenancy.config.payments.stripeAccountId,
components: {
payments: {
enabled: true,
features: {
refund_management: true,
dispute_management: true,
capture_payments: true,
},
},
},
});

return {
statusCode: 200,
bodyType: "json",
body: {
client_secret: accountSession.client_secret,
},
};
},
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { ensureItemCustomerTypeMatches } from "@/lib/payments";
import { getPrismaClientForTenancy } from "@/prisma-client";
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
import { SubscriptionStatus } from "@prisma/client";
import { KnownErrors } from "@stackframe/stack-shared";
import { adaptSchema, clientOrHigherAuthTypeSchema, yupNumber, yupObject, yupString, offerSchema } from "@stackframe/stack-shared/dist/schema-fields";
import * as yup from "yup";
import { getOrUndefined } from "@stackframe/stack-shared/dist/utils/objects";

export const GET = createSmartRouteHandler({
metadata: {
hidden: true,
},
request: yupObject({
auth: yupObject({
type: clientOrHigherAuthTypeSchema.defined(),
project: adaptSchema.defined(),
tenancy: adaptSchema.defined(),
}).defined(),
params: yupObject({
customer_id: yupString().defined(),
item_id: yupString().defined(),
}).defined(),
}),
response: yupObject({
statusCode: yupNumber().oneOf([200]).defined(),
bodyType: yupString().oneOf(["json"]).defined(),
body: yupObject({
id: yupString().defined(),
display_name: yupString().defined(),
quantity: yupNumber().defined(),
}).defined(),
}),
handler: async (req) => {
const { tenancy } = req.auth;
const paymentsConfig = tenancy.config.payments;

const itemConfig = getOrUndefined(paymentsConfig.items, req.params.item_id);
if (!itemConfig) {
throw new KnownErrors.ItemNotFound(req.params.item_id);
}

await ensureItemCustomerTypeMatches(req.params.item_id, itemConfig.customerType, req.params.customer_id, tenancy);
const prisma = await getPrismaClientForTenancy(tenancy);
const subscriptions = await prisma.subscription.findMany({
where: {
tenancyId: tenancy.id,
customerId: req.params.customer_id,
status: {
in: [SubscriptionStatus.active, SubscriptionStatus.trialing],
}
},
});

const totalQuantity = subscriptions.reduce((acc, subscription) => {
const offer = subscription.offer as yup.InferType<typeof offerSchema>;
const item = getOrUndefined(offer.includedItems, req.params.item_id);
return acc + (item?.quantity ?? 0);
}, 0);


return {
statusCode: 200,
bodyType: "json",
body: {
id: req.params.item_id,
display_name: itemConfig.displayName,
quantity: totalQuantity,
},
};
},
});
Loading