-
Notifications
You must be signed in to change notification settings - Fork 501
Payment tests, account status, smartRoutes #828
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
61 commits
Select commit
Hold shift + click to select a range
1798e52
New payments
N2D4 d37c619
Merge branch 'dev' into payments-release
N2D4 83fdba5
fixes
N2D4 020336e
More changes
N2D4 9e589d9
Fixes
N2D4 fef654e
Merge dev into payments-release
N2D4 cbe2a08
Merge dev into payments-release
N2D4 de48a50
Merge dev into payments-release
N2D4 3a021cc
Fixes
N2D4 0736fd9
Merge branch 'dev' into payments-release
N2D4 cad3b39
Fixes
N2D4 57177be
TypeScript mess
N2D4 6c17ed7
more
N2D4 b23aee5
more
N2D4 5c005de
fixes
N2D4 a7fb8ae
fix
N2D4 3d1046f
more fixes
N2D4 5332b03
fixes
N2D4 a70f59d
more fixes
N2D4 805c396
More fixes
N2D4 3ef091c
Merge branch 'dev' into payments-release
N2D4 00116c9
more fixes
N2D4 e027292
Merge branch 'dev' into payments-release
N2D4 701987a
more
N2D4 7c0d78c
Merge branch 'dev' into payments-release
N2D4 ebd6e86
More parts
N2D4 5686372
Apply suggestion from @coderabbitai[bot]
N2D4 4d79129
Merge dev into payments-release
N2D4 77a6358
Merge dev into payments-release
N2D4 1ff14ff
Merge dev into payments-release
N2D4 1c75fed
Merge remote-tracking branch 'origin/dev' into payments-release
BilalG1 1119ca2
feat: Add core payment schema and backend foundation
BilalG1 897b1f5
feat: Add backend payment APIs and Stripe integration
BilalG1 bcb6c11
feat: Add dashboard UI and frontend payment components
BilalG1 1b07baf
add mock stripe key to env
BilalG1 772fac5
Merge dev into payments-release
N2D4 ac574f0
Merge dev into payments-release
N2D4 80271ab
Merge dev into payments-release
N2D4 effebd7
payment tests, account status, smartRoutes
BilalG1 d526863
optional stripe api key
BilalG1 97f8d98
update db schema and migrations
BilalG1 14011ed
remove unused
BilalG1 ec0382a
fix stripe webhook responses
BilalG1 7f701d3
fix stripe response status
BilalG1 fc5cbe8
Merge branch 'payments-release' into payments-pr1-foundation
BilalG1 4c0adc6
Merge branch 'payments-pr1-foundation' into payments-pr2-apis
BilalG1 bdef395
Merge branch 'payments-pr2-apis' into payments-pr3-ui
BilalG1 2fb01c7
Merge branch 'payments-pr3-ui' into payments-pr4-accounts
BilalG1 45ca0c1
remove old migration
BilalG1 3956d0e
remove unused
BilalG1 192d75a
fix pr1 issues
BilalG1 329ac49
pr3 fixes
BilalG1 c66ef0a
pr-4 fixes
BilalG1 9045910
fix price input
BilalG1 a1d22f9
refactored dev enabled
BilalG1 bcfc7c4
async rsc
BilalG1 c3e7358
safe json parse
BilalG1 f728b71
Merge branch 'dev' into payments-pr4-accounts
BilalG1 aa0535d
fix flaky test
BilalG1 972c248
remove unused
BilalG1 f664d41
Merge branch 'dev' into payments-pr4-accounts
BilalG1 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
29 changes: 29 additions & 0 deletions
29
apps/backend/prisma/migrations/20250805195319_subscriptions/migration.sql
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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") | ||
| ); | ||
BilalG1 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| -- CreateIndex | ||
| CREATE UNIQUE INDEX "Subscription_tenancyId_stripeSubscriptionId_key" ON "Subscription"("tenancyId", "stripeSubscriptionId"); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
89 changes: 89 additions & 0 deletions
89
apps/backend/src/app/api/latest/integrations/stripe/webhooks/route.tsx
BilalG1 marked this conversation as resolved.
Show resolved
Hide resolved
BilalG1 marked this conversation as resolved.
Show resolved
Hide resolved
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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, | ||
BilalG1 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| signature, | ||
| getEnvVariable("STACK_STRIPE_WEBHOOK_SECRET"), | ||
| ); | ||
|
|
||
BilalG1 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| 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
67
apps/backend/src/app/api/latest/internal/payments/setup/route.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 }, | ||
| }; | ||
| }, | ||
| }); |
52 changes: 52 additions & 0 deletions
52
apps/backend/src/app/api/latest/internal/payments/stripe-widgets/account-session/route.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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"); | ||
| } | ||
|
|
||
BilalG1 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| 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, | ||
| }, | ||
| }; | ||
| }, | ||
| }); | ||
72 changes: 72 additions & 0 deletions
72
apps/backend/src/app/api/latest/payments/items/[customer_id]/[item_id]/route.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | ||
| } | ||
BilalG1 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| 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); | ||
BilalG1 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
|
|
||
| return { | ||
| statusCode: 200, | ||
| bodyType: "json", | ||
| body: { | ||
| id: req.params.item_id, | ||
| display_name: itemConfig.displayName, | ||
| quantity: totalQuantity, | ||
| }, | ||
| }; | ||
| }, | ||
| }); | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.