mirror of
https://github.com/hcengineering/platform.git
synced 2026-08-17 18:05:42 +02:00
Initial Stripe support (#10392)
Signed-off-by: Artem Savchenko <armisav@gmail.com>
This commit is contained in:
Generated
+57
-1
@@ -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
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
.env
|
||||
@@ -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.')
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user