Initial Stripe support (#10392)

Signed-off-by: Artem Savchenko <armisav@gmail.com>
This commit is contained in:
Artyom Savchenko
2026-01-12 13:21:24 +07:00
committed by GitHub
parent ab9daa4991
commit 36927fbd98
13 changed files with 1006 additions and 2 deletions
+57 -1
View File
@@ -39857,6 +39857,9 @@ importers:
postgres:
specifier: ^3.4.7
version: 3.4.7
stripe:
specifier: ^17.3.1
version: 17.7.0
uuid:
specifier: ^8.3.2
version: 8.3.2
@@ -39900,6 +39903,9 @@ importers:
'@typescript-eslint/parser':
specifier: ^6.21.0
version: 6.21.0(eslint@8.57.1)(typescript@5.9.3)
cross-env:
specifier: ~7.0.3
version: 7.0.3
esbuild:
specifier: ^0.25.10
version: 0.25.12
@@ -50619,6 +50625,10 @@ packages:
resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
engines: {node: '>=8'}
stripe@17.7.0:
resolution: {integrity: sha512-aT2BU9KkizY9SATf14WhhYVv2uOapBWX0OFWF4xvcj1mPaNotlSc2CsxpS4DS46ZueSppmCF5BX1sNYBtwBvfw==}
engines: {node: '>=12.*'}
striptags@3.2.0:
resolution: {integrity: sha512-g45ZOGzHDMe2bdYMdIvdAfCQkCTDMGBazSw1ypMowwGIee7ZQ5dU0rBJ8Jqgl+jAKIv4dbeE1jscZq9wid1Tkw==}
@@ -61258,7 +61268,7 @@ snapshots:
node-loader@2.0.0(webpack@5.102.1):
dependencies:
loader-utils: 2.0.4
webpack: 5.102.1(@swc/core@1.15.1)(esbuild@0.25.12)(webpack-cli@5.1.4)
webpack: 5.102.1
node-localstorage@2.2.1:
dependencies:
@@ -62975,6 +62985,11 @@ snapshots:
strip-json-comments@3.1.1: {}
stripe@17.7.0:
dependencies:
'@types/node': 22.19.0
qs: 6.11.2
striptags@3.2.0: {}
strnum@1.1.2: {}
@@ -63289,6 +63304,15 @@ snapshots:
optionalDependencies:
'@swc/core': 1.15.1
terser-webpack-plugin@5.3.14(webpack@5.102.1):
dependencies:
'@jridgewell/trace-mapping': 0.3.31
jest-worker: 27.5.1
schema-utils: 4.3.3
serialize-javascript: 6.0.2
terser: 5.44.1
webpack: 5.102.1
terser@5.44.1:
dependencies:
'@jridgewell/source-map': 0.3.11
@@ -63955,6 +63979,38 @@ snapshots:
webpack-sources@3.3.3: {}
webpack@5.102.1:
dependencies:
'@types/eslint-scope': 3.7.7
'@types/estree': 1.0.8
'@types/json-schema': 7.0.15
'@webassemblyjs/ast': 1.14.1
'@webassemblyjs/wasm-edit': 1.14.1
'@webassemblyjs/wasm-parser': 1.14.1
acorn: 8.15.0
acorn-import-phases: 1.0.4(acorn@8.15.0)
browserslist: 4.28.1
chrome-trace-event: 1.0.4
enhanced-resolve: 5.18.3
es-module-lexer: 1.7.0
eslint-scope: 5.1.1
events: 3.3.0
glob-to-regexp: 0.4.1
graceful-fs: 4.2.11
json-parse-even-better-errors: 2.3.1
loader-runner: 4.3.1
mime-types: 2.1.35
neo-async: 2.6.2
schema-utils: 4.3.3
tapable: 2.3.0
terser-webpack-plugin: 5.3.14(webpack@5.102.1)
watchpack: 2.4.4
webpack-sources: 3.3.3
transitivePeerDependencies:
- '@swc/core'
- esbuild
- uglify-js
webpack@5.102.1(@swc/core@1.15.1):
dependencies:
'@types/eslint-scope': 3.7.7
+1
View File
@@ -0,0 +1 @@
.env
+29 -1
View File
@@ -60,6 +60,16 @@ FRONT_URL/workbench/setting/setting/billing?payment=success&checkout_id={CHECKOU
| `/api/v1/subscriptions/:subscriptionId/cancel` | POST | Cancel subscription |
| `/api/v1/webhooks/:provider` | POST | Receive webhook events from payment provider |
## Running Locally
To run the payment service locally:
```bash
rushx run-local
```
Make sure you have all required environment variables configured (see [Environment Configuration](#environment-configuration) below).
## Environment Configuration
### Required Variables
@@ -84,9 +94,27 @@ To enable Polar.sh as the payment provider, set:
**Webhook** endpoint is registered at `/api/v1/webhooks/polar`.
### Stripe Provider
To enable Stripe as the payment provider, set:
| Variable | Description | Example |
|----------|-------------|---------|
| `STRIPE_API_KEY` | Stripe API secret key | `sk_live_...` or `sk_test_...` |
| `STRIPE_WEBHOOK_SECRET` | Webhook signing secret | `whsec_...` |
| `STRIPE_SUBSCRIPTION_PLANS` | Plan to price ID mapping | `common@tier:price_1a;rare@tier:price_2;epic@tier:price_3;legendary@tier:price_4` |
**Format:** `{plan}@{type}:{priceId};...` where `priceId` is the Stripe Price ID (e.g., `price_1abc123`).
**Note:** Stripe uses Price IDs (not Product IDs like Polar.sh). You can find Price IDs in your Stripe Dashboard under Products.
**Webhook** endpoint is registered at `/api/v1/webhooks/stripe`.
**Important:** Only one provider can be active at a time. If Polar.sh is configured, Stripe will not be initialized. Remove Polar.sh environment variables to use Stripe instead.
### Multiple Providers
To support additional providers (Stripe, Lemonsqueezy, etc.):
To support additional providers (Lemonsqueezy, etc.):
1. Add environment variables for that provider
2. Create provider implementation extending `PaymentProvider` interface
@@ -40,6 +40,7 @@
"@types/ws": "^8.5.12",
"@typescript-eslint/eslint-plugin": "^6.21.0",
"@typescript-eslint/parser": "^6.21.0",
"cross-env": "~7.0.3",
"esbuild": "^0.25.10",
"eslint": "^8.54.0",
"eslint-config-standard-with-typescript": "^40.0.0",
@@ -67,6 +68,7 @@
"@hcengineering/account-client": "workspace:^0.7.21",
"@hcengineering/platform": "workspace:^0.7.19",
"@polar-sh/sdk": "^0.37.0",
"stripe": "^17.3.1",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.21.2",
@@ -12,6 +12,9 @@
// See the License for the specific language governing permissions and
// limitations under the License.
//
import { config as dotenvConfig } from 'dotenv'
dotenvConfig()
export interface Config {
Port: number
@@ -26,6 +29,11 @@ export interface Config {
PolarOrganizationId?: string
PolarSubscriptionPlans?: string
// Stripe configuration
StripeApiKey?: string
StripeWebhookSecret?: string
StripeSubscriptionPlans?: string
ReconciliationIntervalMinutes?: number
}
@@ -42,6 +50,9 @@ const config: Config = (() => {
PolarWebhookSecret: process.env.POLAR_WEBHOOK_SECRET,
PolarOrganizationId: process.env.POLAR_ORGANIZATION_ID,
PolarSubscriptionPlans: process.env.POLAR_SUBSCRIPTION_PLANS,
StripeApiKey: process.env.STRIPE_API_KEY,
StripeWebhookSecret: process.env.STRIPE_WEBHOOK_SECRET,
StripeSubscriptionPlans: process.env.STRIPE_SUBSCRIPTION_PLANS,
ReconciliationIntervalMinutes: parseNumber(process.env.RECONCILIATION_INTERVAL_MINUTES)
}
@@ -16,6 +16,7 @@
import { type AccountClient } from '@hcengineering/account-client'
import type { PaymentProvider } from './providers'
import { PolarProvider } from './providers/polar/provider'
import { StripeProvider } from './providers/stripe/provider'
/**
* Static singleton factory for creating payment providers
@@ -47,6 +48,8 @@ export class PaymentProviderFactory {
switch (type) {
case 'polar':
return this.createPolarProvider(config, accountClient, useSandbox)
case 'stripe':
return this.createStripeProvider(config, accountClient)
default:
return undefined
}
@@ -79,4 +82,27 @@ export class PaymentProviderFactory {
useSandbox
)
}
private createStripeProvider (config: Record<string, any>, accountClient: AccountClient): PaymentProvider {
if (config.apiKey === undefined) {
throw new Error('Stripe provider requires apiKey in config')
}
if (config.webhookSecret === undefined) {
throw new Error('Stripe provider requires webhookSecret in config')
}
if (config.subscriptionPlans === undefined) {
throw new Error('Stripe provider requires subscriptionPlans in config')
}
if (config.frontUrl === undefined) {
throw new Error('Stripe provider requires frontUrl in config')
}
return new StripeProvider(
config.apiKey,
config.webhookSecret,
config.subscriptionPlans,
config.frontUrl,
accountClient
)
}
}
@@ -0,0 +1,184 @@
//
// Copyright © 2025 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
//
import { MeasureContext } from '@hcengineering/core'
import Stripe from 'stripe'
import type { CheckoutResult, CreateCheckoutParams } from './types'
/**
* Documentation: https://stripe.com/docs/api
*/
export class StripeClient {
private readonly stripe: Stripe
constructor (apiKey: string) {
this.stripe = new Stripe(apiKey, {
apiVersion: '2025-02-24.acacia'
})
}
/**
* Create a checkout session for a subscription
*/
async createCheckout (ctx: MeasureContext, params: CreateCheckoutParams): Promise<CheckoutResult> {
return await ctx.with('stripe-create-checkout', {}, async () => {
const sessionParams: Stripe.Checkout.SessionCreateParams = {
mode: 'subscription',
line_items: [
{
price: params.priceId,
quantity: 1
}
],
success_url: params.successUrl,
cancel_url: params.cancelUrl ?? params.successUrl,
metadata: {
workspaceUuid: params.metadata.workspaceUuid,
subscriptionType: params.metadata.subscriptionType,
subscriptionPlan: params.metadata.subscriptionPlan
}
}
if (params.customerId !== undefined) {
sessionParams.customer = params.customerId
} else if (params.customerEmail !== undefined) {
sessionParams.customer_email = params.customerEmail
}
if (params.subscriptionId !== undefined) {
sessionParams.subscription_data = {
metadata: {
workspaceUuid: params.metadata.workspaceUuid,
subscriptionType: params.metadata.subscriptionType,
subscriptionPlan: params.metadata.subscriptionPlan
}
}
}
const session = await this.stripe.checkout.sessions.create(sessionParams)
return {
checkoutId: session.id,
url: session.url ?? ''
}
})
}
/**
* Get checkout session by ID
*/
async getCheckout (ctx: MeasureContext, checkoutId: string): Promise<Stripe.Checkout.Session> {
return await ctx.with('stripe-get-checkout', {}, async () => {
return await this.stripe.checkout.sessions.retrieve(checkoutId, {
expand: ['subscription']
})
})
}
/**
* Get subscription by ID
*/
async getSubscription (ctx: MeasureContext, subscriptionId: string): Promise<Stripe.Subscription> {
return await ctx.with('stripe-get-subscription', {}, async () => {
return await this.stripe.subscriptions.retrieve(subscriptionId, {
expand: ['customer']
})
})
}
/**
* Get all active subscriptions
* Filters subscriptions by status='active' or status='trialing'
* Filters by customer if provided
* This is used internally by the provider for reconciliation
*/
async getActiveSubscriptions (ctx: MeasureContext, customerId?: string): Promise<Stripe.Subscription[]> {
return await ctx.with('stripe-get-active-subscriptions', {}, async () => {
const subscriptions: Stripe.Subscription[] = []
const params: Stripe.SubscriptionListParams = {
limit: 100,
status: 'all'
}
if (customerId !== undefined) {
params.customer = customerId
}
let hasMore = true
let startingAfter: string | undefined
while (hasMore) {
if (startingAfter !== undefined) {
params.starting_after = startingAfter
}
const response = await this.stripe.subscriptions.list(params)
subscriptions.push(
...response.data.filter((sub: Stripe.Subscription) => sub.status === 'active' || sub.status === 'trialing')
)
hasMore = response.has_more
if (hasMore && response.data.length > 0) {
startingAfter = response.data[response.data.length - 1].id
}
}
return subscriptions
})
}
/**
* Cancel a subscription
* Stripe subscriptions are canceled via cancel endpoint
* Documentation: https://stripe.com/docs/api/subscriptions/cancel
*/
async cancelSubscription (ctx: MeasureContext, subscriptionId: string): Promise<Stripe.Subscription> {
return await ctx.with('stripe-cancel-subscription', {}, async () => {
return await this.stripe.subscriptions.cancel(subscriptionId)
})
}
/**
* Uncancel a subscription (reactivate a previously canceled subscription)
* Stripe subscriptions are uncanceled by removing cancel_at_period_end flag
* Documentation: https://stripe.com/docs/api/subscriptions/update
*/
async uncancelSubscription (ctx: MeasureContext, subscriptionId: string): Promise<Stripe.Subscription> {
return await ctx.with('stripe-uncancel-subscription', {}, async () => {
return await this.stripe.subscriptions.update(subscriptionId, {
cancel_at_period_end: false
})
})
}
/**
* Update a subscription to a different price
* Changes the subscription to use a new price with immediate effective date and proration
* Documentation: https://stripe.com/docs/api/subscriptions/update
*/
async updateSubscription (ctx: MeasureContext, subscriptionId: string, priceId: string): Promise<Stripe.Subscription> {
return await ctx.with('stripe-update-subscription', {}, async () => {
const subscription = await this.stripe.subscriptions.retrieve(subscriptionId)
return await this.stripe.subscriptions.update(subscriptionId, {
items: [
{
id: subscription.items.data[0].id,
price: priceId
}
],
proration_behavior: 'create_prorations'
})
})
}
}
@@ -0,0 +1,19 @@
//
// Copyright © 2025 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
//
export * from './types'
export * from './client'
export * from './webhook'
export { StripeProvider } from './provider'
@@ -0,0 +1,342 @@
//
// Copyright © 2025 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
//
import type { MeasureContext, WorkspaceUuid } from '@hcengineering/core'
import type { Express, Request, Response } from 'express'
import type Stripe from 'stripe'
import {
AccountClient,
SubscriptionType,
type Subscription,
type SubscriptionData
} from '@hcengineering/account-client'
import type { PaymentProvider, SubscribeRequest, CheckoutResponse } from '../index'
import { StripeClient } from './client'
import { handleStripeWebhook } from './webhook'
import { transformStripeSubscriptionToData } from './utils'
import { getPlanKey } from '../../utils'
/**
* Check if a subscription has changed by comparing modifiedAt timestamps
* Returns true if the provider's version is newer than what we have stored
*/
function hasSubscriptionChanged (ourSub: SubscriptionData, newData: SubscriptionData): boolean {
const ourModifiedAt = ourSub.providerData?.modifiedAt
const newModifiedAt = newData.providerData?.modifiedAt
if (newModifiedAt === undefined) {
return false
}
if (ourModifiedAt === undefined) {
return true
}
return newModifiedAt > ourModifiedAt
}
/**
* Stripe implementation of PaymentProvider
*/
export class StripeProvider implements PaymentProvider {
readonly providerName = 'stripe'
private readonly stripe: StripeClient
private readonly webhookSecret: string
// Map: plan@type (Huly) -> priceId (Stripe)
private readonly subscriptionPlans: Record<string, string>
private readonly frontUrl: string
private readonly accountClient: AccountClient
constructor (
apiKey: string,
webhookSecret: string,
subscriptionPlans: string,
frontUrl: string,
accountClient: AccountClient
) {
this.stripe = new StripeClient(apiKey)
this.webhookSecret = webhookSecret
// TODO: support branding
this.frontUrl = frontUrl
this.subscriptionPlans = {}
this.accountClient = accountClient
const plans = subscriptionPlans.split(';')
for (const plan of plans) {
const [type, priceId] = plan.split(':')
this.subscriptionPlans[type] = priceId
}
// TODO: verify all plans are present in the config - take them from model?
// hardcoded check for now
const mustHave = ['common@tier', 'rare@tier', 'epic@tier', 'legendary@tier']
for (const plan of mustHave) {
if (this.subscriptionPlans[plan] === undefined) {
throw new Error(`Missing plan in config: ${plan}`)
}
}
}
async createSubscription (
ctx: MeasureContext,
request: SubscribeRequest,
workspaceUuid: WorkspaceUuid,
workspaceUrl: string,
accountUuid: string
): Promise<CheckoutResponse> {
ctx.info('Creating Stripe subscription', { type: request.type, plan: request.plan })
const planKey = getPlanKey(request.type, request.plan)
const priceId = this.subscriptionPlans[planKey]
if (priceId === undefined) {
throw new Error(`Missing priceId for plan: ${planKey}`)
}
const successUrl = `${this.frontUrl}/workbench/${workspaceUrl}/setting/setting/billing/subscriptions?payment=success&checkout_id={CHECKOUT_SESSION_ID}`
const cancelUrl = `${this.frontUrl}/workbench/${workspaceUrl}/setting/setting/billing/subscriptions?payment=canceled`
const response = await this.stripe.createCheckout(ctx, {
priceId,
successUrl,
cancelUrl,
customerEmail: request.customerEmail,
customerName: request.customerName,
metadata: {
workspaceUuid,
subscriptionType: request.type,
subscriptionPlan: request.plan
}
})
return {
checkoutId: response.checkoutId,
checkoutUrl: response.url
}
}
async getSubscription (ctx: MeasureContext, subscriptionId: string): Promise<SubscriptionData | null> {
const stripeSubscription = await this.stripe.getSubscription(ctx, subscriptionId)
const subscriptionData = transformStripeSubscriptionToData(stripeSubscription)
if (subscriptionData === null) {
return null
}
return subscriptionData
}
async getSubscriptionByCheckout (ctx: MeasureContext, checkoutId: string): Promise<SubscriptionData | null> {
try {
const checkout = await this.stripe.getCheckout(ctx, checkoutId)
// If checkout is not complete, no subscription yet
if (checkout.status !== 'complete') {
return null
}
// If checkout has a subscription ID, fetch it directly
if (checkout.subscription != null) {
const subscriptionId =
typeof checkout.subscription === 'string' ? checkout.subscription : checkout.subscription.id
const subscription = await this.stripe.getSubscription(ctx, subscriptionId)
const subscriptionData = transformStripeSubscriptionToData(subscription)
if (subscriptionData !== null) {
return subscriptionData
}
}
// If we have a customer ID, try to find the subscription
const customerId = typeof checkout.customer === 'string' ? checkout.customer : checkout.customer?.id
if (customerId === undefined || customerId === null) {
ctx.error('Cannot search subscriptions: no customer ID in checkout', { checkoutId })
return null
}
const activeSubscriptions = await this.stripe.getActiveSubscriptions(ctx, customerId)
// Find the most recent subscription for this customer (likely created from this checkout)
if (activeSubscriptions.length > 0) {
// Sort by created date, most recent first
activeSubscriptions.sort((a, b) => b.created - a.created)
const subscriptionData = transformStripeSubscriptionToData(activeSubscriptions[0])
if (subscriptionData !== null) {
return subscriptionData
}
}
return null
} catch (err) {
ctx.error('Failed to get subscription by checkout', { checkoutId, err })
return null
}
}
async reconcileActiveSubscriptions (ctx: MeasureContext, accountsUrl: string, serviceToken: string): Promise<void> {
try {
ctx.info('Starting Stripe active subscription reconciliation')
const stripeActiveSubscriptions = await this.stripe.getActiveSubscriptions(ctx)
const ourActiveSubscriptions = await this.accountClient.getSubscriptions()
const ourSubsByProviderId = new Map(
ourActiveSubscriptions.map((sub: Subscription) => [sub.providerSubscriptionId, sub])
)
const stripeActiveIds = new Set(stripeActiveSubscriptions.map((sub: Stripe.Subscription) => sub.id))
// Step 1: Update subscriptions that exist in Stripe and have changed
let upsertCount = 0
for (const stripeSub of stripeActiveSubscriptions) {
try {
const subscriptionData = transformStripeSubscriptionToData(stripeSub)
if (subscriptionData === null) {
continue
}
const ourSub = ourSubsByProviderId.get(stripeSub.id)
// Only upsert if subscription doesn't exist locally or if key fields have changed
if (ourSub === undefined || hasSubscriptionChanged(ourSub, subscriptionData)) {
await this.accountClient.upsertSubscription(subscriptionData)
upsertCount++
}
} catch (err) {
ctx.error('Failed to upsert active subscription', {
providerSubId: stripeSub.id,
err
})
}
}
// Step 2: Check for subscriptions we think are active but Stripe says aren't
let staleCount = 0
for (const ourSub of ourActiveSubscriptions) {
const stripeSubId = ourSub.providerSubscriptionId
if (!stripeActiveIds.has(stripeSubId)) {
try {
// Fetch the current state from Stripe directly
const currentState = await this.stripe.getSubscription(ctx, stripeSubId)
const subscriptionData = transformStripeSubscriptionToData(currentState)
// Update our database with current state (may have changed to canceled/ended)
if (subscriptionData !== null) {
await this.accountClient.upsertSubscription(subscriptionData)
staleCount++
}
} catch (err) {
ctx.error('Failed to reconcile subscription status', {
subscriptionId: stripeSubId,
err
})
}
}
}
ctx.info('Stripe subscription reconciliation completed', {
stripeActiveCount: stripeActiveSubscriptions.length,
ourActiveCount: ourActiveSubscriptions.length,
upsertedCount: upsertCount,
staleUpdatedCount: staleCount
})
} catch (err) {
ctx.error('Stripe subscription reconciliation failed', { err })
throw err
}
}
async cancelSubscription (ctx: MeasureContext, providerSubscriptionId: string): Promise<SubscriptionData> {
const stripeSubscription = await this.stripe.cancelSubscription(ctx, providerSubscriptionId)
const subscriptionData = transformStripeSubscriptionToData(stripeSubscription)
if (subscriptionData == null) {
throw new Error(`Failed to cancel subscription ${providerSubscriptionId}`)
}
return subscriptionData
}
async uncancelSubscription (ctx: MeasureContext, providerSubscriptionId: string): Promise<SubscriptionData> {
const stripeSubscription = await this.stripe.uncancelSubscription(ctx, providerSubscriptionId)
const subscriptionData = transformStripeSubscriptionToData(stripeSubscription)
if (subscriptionData == null) {
throw new Error(`Failed to uncancel subscription ${providerSubscriptionId}`)
}
return subscriptionData
}
async updateSubscriptionPlan (
ctx: MeasureContext,
subscriptionId: string,
newPlan: string,
workspaceUrl: string
): Promise<SubscriptionData | CheckoutResponse | null> {
// Get the current subscription to check if it's free
const currentSub = await this.stripe.getSubscription(ctx, subscriptionId)
// Check if subscription is free by checking if the price amount is 0
const price = currentSub.items.data[0]?.price
const isFreeSubscription = price?.unit_amount === 0 || price === undefined
// Get the Stripe price ID for the new plan (subscriptions updates are tier type)
const planKey = getPlanKey(SubscriptionType.Tier, newPlan)
const priceId = this.subscriptionPlans[planKey]
if (priceId === undefined) {
throw new Error(`No price configured for plan: ${planKey}`)
}
// If subscription is free, create a checkout instead of updating directly
if (isFreeSubscription) {
const successUrl = `${this.frontUrl}/workbench/${workspaceUrl}/setting/setting/billing/subscriptions?payment=success&checkout_id={CHECKOUT_SESSION_ID}`
const cancelUrl = `${this.frontUrl}/workbench/${workspaceUrl}/setting/setting/billing/subscriptions?payment=canceled`
const customerId = typeof currentSub.customer === 'string' ? currentSub.customer : currentSub.customer?.id
const metadata = currentSub.metadata ?? {}
const response = await this.stripe.createCheckout(ctx, {
priceId,
successUrl,
cancelUrl,
customerId,
subscriptionId: currentSub.id,
metadata: {
workspaceUuid: metadata.workspaceUuid,
subscriptionType: SubscriptionType.Tier,
subscriptionPlan: newPlan
}
})
return {
checkoutId: response.checkoutId,
checkoutUrl: response.url
}
}
// Update the subscription to the new price
const updatedSub = await this.stripe.updateSubscription(ctx, subscriptionId, priceId)
// Transform and return the updated subscription data
const subscriptionData = transformStripeSubscriptionToData(updatedSub)
return subscriptionData
}
registerWebhookEndpoints (app: Express, ctx: MeasureContext, accountsUrl: string, serviceToken: string): void {
ctx.info('Registering Stripe webhook endpoints')
// Register Stripe-specific webhook endpoint (body parsing handled by server middleware)
app.post('/api/v1/webhooks/stripe', (req: Request, res: Response) => {
void handleStripeWebhook(ctx, accountsUrl, serviceToken, this.webhookSecret, req, res)
})
}
}
@@ -0,0 +1,54 @@
//
// Copyright © 2025 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
//
/**
* Stripe integration types
* Uses stripe SDK for API interactions
* Documentation: https://stripe.com/docs/api
*/
/**
* Webhook event wrapper
*/
export interface StripeWebhookEvent {
type: string
data: any
}
export interface CreateCheckoutMetadata {
workspaceUuid: string
subscriptionType: string
subscriptionPlan: string
[key: string]: string | number | boolean
}
/**
* Internal types for checkout creation
* Note: Stripe uses 'price' parameter (price ID)
*/
export interface CreateCheckoutParams {
priceId: string
successUrl: string
cancelUrl?: string
customerId?: string
customerEmail?: string
customerName?: string
metadata: CreateCheckoutMetadata
subscriptionId?: string
}
export interface CheckoutResult {
checkoutId: string
url: string
}
@@ -0,0 +1,131 @@
//
// Copyright © 2025 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
//
import { type AccountUuid, type WorkspaceUuid } from '@hcengineering/core'
import type { SubscriptionData } from '@hcengineering/account-client'
import { SubscriptionStatus, SubscriptionType } from '@hcengineering/account-client'
import type Stripe from 'stripe'
/**
* Map Stripe subscription status to our SubscriptionStatus
*/
function mapStripeStatus (stripeStatus: Stripe.Subscription.Status): SubscriptionStatus | null {
switch (stripeStatus) {
case 'active':
return 'active' as SubscriptionStatus
case 'trialing':
return 'trialing' as SubscriptionStatus
case 'past_due':
return 'past_due' as SubscriptionStatus
case 'canceled':
case 'unpaid':
case 'incomplete':
case 'incomplete_expired':
case 'paused':
default:
return null
}
}
/**
* Transform a Stripe subscription to our SubscriptionData format
* Extracts metadata and maps status
* Returns null if subscription is in an irrelevant state
*/
export function transformStripeSubscriptionToData (subscription: Stripe.Subscription): SubscriptionData | null {
const metadata = subscription.metadata ?? {}
const workspaceUuid = metadata.workspaceUuid as WorkspaceUuid | undefined
let accountUuid: AccountUuid | undefined
const subscriptionType = metadata.subscriptionType as SubscriptionType | undefined
const subscriptionPlan = metadata.subscriptionPlan as string | undefined
// For Stripe, customer can be a string ID or expanded Customer object
let customerId: string | undefined
if (typeof subscription.customer === 'string') {
customerId = subscription.customer
} else if (subscription.customer !== null && subscription.customer.deleted !== true) {
// subscription.customer is Customer (not DeletedCustomer)
customerId = subscription.customer.id
accountUuid = subscription.customer.id as AccountUuid
// Try to get accountUuid from customer metadata if not in subscription metadata
if (subscription.customer.metadata?.accountUuid !== undefined) {
accountUuid = subscription.customer.metadata.accountUuid as AccountUuid
}
}
if (
accountUuid === undefined ||
workspaceUuid === undefined ||
subscriptionType === undefined ||
subscriptionPlan === undefined
) {
return null
}
// Get the price amount from the subscription
const price = subscription.items.data[0]?.price
const amount = price?.unit_amount ?? undefined
// Map Stripe status to our SubscriptionStatus
const status = mapStripeStatus(subscription.status)
if (status === null) {
// Ignore updates for subscriptions in irrelevant states
return null
}
// Handle optional periodEnd field
let periodEnd: number | undefined
if (subscription.current_period_end != null) {
periodEnd = subscription.current_period_end * 1000 // Convert from Unix timestamp to milliseconds
}
let trialEnd: number | undefined
if (subscription.trial_end != null) {
trialEnd = subscription.trial_end * 1000 // Convert from Unix timestamp to milliseconds
}
let canceledAt: number | undefined
if (subscription.canceled_at != null) {
canceledAt = subscription.canceled_at * 1000 // Convert from Unix timestamp to milliseconds
}
const subscriptionData: SubscriptionData = {
id: `stripe_${subscription.id}`, // Composite ID with provider prefix to avoid conflicts
workspaceUuid,
accountUuid,
provider: 'stripe',
providerSubscriptionId: subscription.id,
providerCheckoutId: subscription.latest_invoice as string | undefined,
type: subscriptionType,
status,
plan: subscriptionPlan,
amount, // Amount paid in cents (e.g. 9999 for $99.99)
periodStart: subscription.current_period_start * 1000, // Convert from Unix timestamp to milliseconds
periodEnd,
trialEnd,
canceledAt,
providerData: {
modifiedAt: subscription.created * 1000, // Convert from Unix timestamp to milliseconds (using created as updated doesn't exist)
customerId,
status: subscription.status,
cancelAtPeriodEnd: subscription.cancel_at_period_end,
endedAt: subscription.ended_at != null ? subscription.ended_at * 1000 : undefined,
cancelReason: subscription.cancellation_details?.reason ?? undefined,
cancelComment: subscription.cancellation_details?.comment ?? undefined
}
}
return subscriptionData
}
@@ -0,0 +1,118 @@
//
// Copyright © 2025 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
//
import type { Request, Response } from 'express'
import Stripe from 'stripe'
import { type MeasureContext } from '@hcengineering/core'
import { getAccountClient } from '../../utils'
import { transformStripeSubscriptionToData } from './utils'
/**
* Handle Stripe webhook events
* Uses Stripe SDK for webhook validation
* Webhooks are sent for checkout and subscription lifecycle events
* Documentation: https://stripe.com/docs/webhooks
*/
export async function handleStripeWebhook (
ctx: MeasureContext,
accountsUrl: string,
serviceToken: string,
webhookSecret: string,
req: Request,
res: Response
): Promise<void> {
try {
// Body is a Buffer from express.raw() middleware
const rawBody = req.body as Buffer
const sig = req.headers['stripe-signature'] as string
if (!(rawBody instanceof Buffer) || rawBody.length === 0) {
ctx.error('Invalid webhook body')
res.status(400).json({ error: 'Invalid body' })
return
}
if (sig === undefined) {
ctx.error('Missing Stripe signature header')
res.status(400).json({ error: 'Missing signature' })
return
}
// Create Stripe instance for webhook verification
const stripe = new Stripe('', { apiVersion: '2025-02-24.acacia' })
// Verify webhook signature and parse event
let event: Stripe.Event
try {
event = stripe.webhooks.constructEvent(rawBody, sig, webhookSecret)
} catch (err: any) {
ctx.error('Invalid Stripe webhook signature', { err })
res.status(403).json({ error: 'Invalid signature' })
return
}
// Route to appropriate handler based on event type
switch (event.type) {
case 'customer.subscription.created':
case 'customer.subscription.updated':
case 'customer.subscription.deleted':
case 'invoice.payment_succeeded':
case 'invoice.payment_failed':
void handleSubscriptionUpdated(ctx, accountsUrl, serviceToken, event).catch((err) => {
ctx.error('Failed to process Stripe webhook event', { event, err })
})
break
default:
ctx.info('Unhandled Stripe webhook event type', { type: event.type })
}
res.status(200).json({ received: true })
} catch (err) {
ctx.error('Failed to process Stripe webhook', { err })
res.status(500).json({ error: 'Internal server error' })
}
}
/**
* Handle subscription.created/updated/deleted
*/
async function handleSubscriptionUpdated (
ctx: MeasureContext,
accountsUrl: string,
serviceToken: string,
event: Stripe.Event
): Promise<void> {
const subscription = event.data.object as Stripe.Subscription
if (subscription == null) {
ctx.error('Missing subscription data', { event })
throw new Error('Missing subscription data')
}
const subscriptionData = transformStripeSubscriptionToData(subscription)
if (subscriptionData === null) {
ctx.info('Ignoring subscription in irrelevant state', {
subscriptionId: subscription.id,
status: subscription.status
})
return
}
const accountClient = getAccountClient(accountsUrl, serviceToken)
await accountClient.upsertSubscription(subscriptionData)
ctx.info('Subscription upserted', { subscriptionId: subscription.id, status: subscriptionData.status })
}
@@ -105,6 +105,7 @@ export async function createServer (ctx: MeasureContext, config: Config): Promis
// Initialize payment provider if configured
let provider: PaymentProvider | undefined
// Try Polar provider first
if (
config.PolarAccessToken !== undefined &&
config.PolarWebhookSecret !== undefined &&
@@ -134,6 +135,37 @@ export async function createServer (ctx: MeasureContext, config: Config): Promis
}
}
// Try Stripe provider if Polar is not configured
if (
provider == null &&
config.StripeApiKey !== undefined &&
config.StripeWebhookSecret !== undefined &&
config.StripeSubscriptionPlans !== undefined
) {
try {
provider = PaymentProviderFactory.getInstance().create(
'stripe',
{
apiKey: config.StripeApiKey,
webhookSecret: config.StripeWebhookSecret,
subscriptionPlans: config.StripeSubscriptionPlans,
frontUrl: config.FrontUrl
},
accountClient,
false
)
if (provider !== undefined) {
// Register provider-specific endpoints (e.g., webhooks)
provider.registerWebhookEndpoints(app, ctx, config.AccountsUrl, serviceToken)
ctx.info('Stripe payment provider initialized successfully')
}
} catch (err) {
ctx.error('Failed to initialize payment provider Stripe', { err })
}
}
if (provider == null) {
throw new Error('Payment provider is not configured. Please provide payment provider configuration.')
}