diff --git a/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml index 3c3c379620..050ea44514 100644 --- a/common/config/rush/pnpm-lock.yaml +++ b/common/config/rush/pnpm-lock.yaml @@ -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 diff --git a/services/payment/pod-payment/.gitignore b/services/payment/pod-payment/.gitignore new file mode 100644 index 0000000000..2eea525d88 --- /dev/null +++ b/services/payment/pod-payment/.gitignore @@ -0,0 +1 @@ +.env \ No newline at end of file diff --git a/services/payment/pod-payment/README.md b/services/payment/pod-payment/README.md index 2f89d18c05..02835cacc6 100644 --- a/services/payment/pod-payment/README.md +++ b/services/payment/pod-payment/README.md @@ -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 diff --git a/services/payment/pod-payment/package.json b/services/payment/pod-payment/package.json index 74301f1151..3873e848a3 100644 --- a/services/payment/pod-payment/package.json +++ b/services/payment/pod-payment/package.json @@ -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", diff --git a/services/payment/pod-payment/src/config.ts b/services/payment/pod-payment/src/config.ts index a4703cf435..5c12fd01ec 100644 --- a/services/payment/pod-payment/src/config.ts +++ b/services/payment/pod-payment/src/config.ts @@ -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) } diff --git a/services/payment/pod-payment/src/factory.ts b/services/payment/pod-payment/src/factory.ts index 436ac1ec02..48c158b2ab 100644 --- a/services/payment/pod-payment/src/factory.ts +++ b/services/payment/pod-payment/src/factory.ts @@ -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, 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 + ) + } } diff --git a/services/payment/pod-payment/src/providers/stripe/client.ts b/services/payment/pod-payment/src/providers/stripe/client.ts new file mode 100644 index 0000000000..5021b88726 --- /dev/null +++ b/services/payment/pod-payment/src/providers/stripe/client.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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' + }) + }) + } +} diff --git a/services/payment/pod-payment/src/providers/stripe/index.ts b/services/payment/pod-payment/src/providers/stripe/index.ts new file mode 100644 index 0000000000..6a7e3bf60b --- /dev/null +++ b/services/payment/pod-payment/src/providers/stripe/index.ts @@ -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' diff --git a/services/payment/pod-payment/src/providers/stripe/provider.ts b/services/payment/pod-payment/src/providers/stripe/provider.ts new file mode 100644 index 0000000000..f231b0502f --- /dev/null +++ b/services/payment/pod-payment/src/providers/stripe/provider.ts @@ -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 + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + // 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) + }) + } +} diff --git a/services/payment/pod-payment/src/providers/stripe/types.ts b/services/payment/pod-payment/src/providers/stripe/types.ts new file mode 100644 index 0000000000..e78de70418 --- /dev/null +++ b/services/payment/pod-payment/src/providers/stripe/types.ts @@ -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 +} diff --git a/services/payment/pod-payment/src/providers/stripe/utils.ts b/services/payment/pod-payment/src/providers/stripe/utils.ts new file mode 100644 index 0000000000..b7fc159251 --- /dev/null +++ b/services/payment/pod-payment/src/providers/stripe/utils.ts @@ -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 +} diff --git a/services/payment/pod-payment/src/providers/stripe/webhook.ts b/services/payment/pod-payment/src/providers/stripe/webhook.ts new file mode 100644 index 0000000000..127e5c8051 --- /dev/null +++ b/services/payment/pod-payment/src/providers/stripe/webhook.ts @@ -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 { + 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 { + 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 }) +} diff --git a/services/payment/pod-payment/src/server.ts b/services/payment/pod-payment/src/server.ts index b65751e5bf..51de273952 100644 --- a/services/payment/pod-payment/src/server.ts +++ b/services/payment/pod-payment/src/server.ts @@ -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.') }