-
Notifications
You must be signed in to change notification settings - Fork 10
add: track org seat usage workflow
#24
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,146 @@ | ||
| import { | ||
| onPostAuthenticationEvent, | ||
| WorkflowSettings, | ||
| WorkflowTrigger, | ||
| createKindeAPI, | ||
| } from "@kinde/infrastructure"; | ||
|
|
||
| /** | ||
| * Workflow: Track Per-User (Seat-Based) Billing Usage in Kinde | ||
| * | ||
| * This workflow is designed for a standard B2B SaaS setup in Kinde, where: | ||
| * - Organizations are billed per active user (seat-based pricing) | ||
| * - Billing is managed by organization administrators | ||
| * - Users can join organizations via orgCode, allowed domains, or custom invite flows | ||
| * | ||
| * This workflow should be triggered after user authentication (PostAuthentication event). | ||
| * It ensures that whenever a new user is added to an organization, the metered usage for the | ||
| * 'user' feature is updated for accurate seat-based billing. | ||
| * | ||
| * Prerequisites: | ||
| * 1. Connect your Stripe account in the Kinde dashboard. | ||
| * 2. Create and publish a per-user (seat-based) billing plan with a metered feature key 'user'. | ||
| * 3. Assign the Billing Admin role to organization creators. | ||
| * 4. Enable organization creation and joining via orgCode or allowed domains. | ||
| * 5. Set up a Kinde M2M application with the following scopes: | ||
| * - read:organizations | ||
| * - create:meter_usage | ||
| * 6. Add the following environment variables in Kinde: | ||
| * - KINDE_WF_M2M_CLIENT_ID | ||
| * - KINDE_WF_M2M_CLIENT_SECRET (set as sensitive) | ||
| * | ||
| * Usage: | ||
| * - This workflow should be used to report seat usage whenever a user is added to an organization. | ||
| * - It can be extended to handle removals or scheduled reconciliation jobs for true-up billing. | ||
| * | ||
| * For more details, see the Kinde B2B SaaS billing guide. | ||
| */ | ||
|
|
||
| export const workflowSettings: WorkflowSettings = { | ||
| id: "trackOrgSeatUsage", | ||
| name: "Track Organization Seat Usage", | ||
| failurePolicy: { | ||
| action: "stop", | ||
| }, | ||
| trigger: WorkflowTrigger.PostAuthentication, | ||
| bindings: { | ||
| "kinde.env": {}, | ||
| "kinde.fetch": {}, | ||
| url: {}, | ||
| }, | ||
| }; | ||
|
|
||
| // The workflow code to be executed when the event is triggered | ||
| /** | ||
| * PostAuthentication workflow handler to track seat usage for billing. | ||
| * | ||
| * Triggered when a user is added to the Kinde user pool for the first time (isNewUserRecordCreated). | ||
| * Looks up the organization and plan, and updates metered usage for the 'user' feature. | ||
| */ | ||
| export default async function trackOrgSeatUsage(event: onPostAuthenticationEvent) { | ||
| // Use optional chaining to safely access nested properties with sensible defaults | ||
| const isNewKindeUser = event?.context?.auth?.isNewUserRecordCreated ?? false; | ||
| const orgCode = event?.request?.authUrlParams?.orgCode; | ||
|
|
||
| console.log('[DEBUG] orgCode from authUrlParams:', orgCode); | ||
| console.log('[DEBUG] isNewKindeUser:', isNewKindeUser); | ||
|
|
||
| // Early return if required properties are missing | ||
| if (!orgCode || !event?.context?.user?.id) { | ||
| console.log('[DEBUG] Missing required parameters (orgCode or user ID). Exiting workflow safely.'); | ||
| return; | ||
| } | ||
|
Comment on lines
+69
to
+72
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Silent functional gap: domain-based and admin-invited users are never counted. The guard Two other first-party Kinde join patterns are silently skipped:
In both cases The JSDoc at the top states the workflow tracks usage "whenever a new user joins an organization" - that claim is currently only true for the org-code flow. You could either:
|
||
|
|
||
| // Only update usage if this is a new user record | ||
| if (!isNewKindeUser) { | ||
| console.log('[DEBUG] User is not new. No seat usage update needed. Exiting workflow safely.'); | ||
| return; | ||
| } | ||
|
|
||
| const kindeUserId = event.context.user.id; | ||
| console.log('[DEBUG] New Kinde user ID:', kindeUserId); | ||
|
|
||
| // Create Kinde Management API client | ||
| const kindeAPI = await createKindeAPI(event); | ||
| console.log('[DEBUG] Kinde API client created'); | ||
|
|
||
| // Fetch organization details (including billing info) | ||
| const orgResponse = await kindeAPI.get({ | ||
| endpoint: `organization?code=${orgCode}&expand=billing`, | ||
| }); | ||
|
Comment on lines
+88
to
+90
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Unguarded async API call (org fetch) is an issue I think. This Additionally, immediately after this call, Required fix: Wrap this call in a The reference implementation for this pattern in this repo is
|
||
| console.log('[DEBUG] orgResponse:', orgResponse); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Production debug logs expose sensitive billing identifiers. That's blocking. This line logs the raw The Looking at the other workflows in this repo ( My thought: Remove all |
||
|
|
||
| const organization = orgResponse.data; | ||
| console.log('[DEBUG] organization:', organization); | ||
| const planCode = "standard-organization-plan"; // Update if your plan code differs | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Hardcoded magic string; should be an environment variable. The Suggested approach: Read the plan code via |
||
| console.log('[DEBUG] planCode:', planCode); | ||
|
|
||
| // Ensure billing data exists | ||
| if (!organization.billing || !organization.billing.agreements || organization.billing.agreements.length === 0) { | ||
| console.log( | ||
| `[INFO] Organization ${orgCode} does not have billing configured or no agreements found. Skipping metered usage update.` | ||
| ); | ||
| return; | ||
| } | ||
|
|
||
| // Find the correct billing agreement for the plan | ||
| const agreement = organization.billing.agreements.find( | ||
| (agr: any) => agr.plan_code === planCode | ||
| ); | ||
|
Comment on lines
+107
to
+109
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Weak typing: Compare to What I would suggest is to define a |
||
| console.log('[DEBUG] agreement:', agreement); | ||
|
|
||
| if (!agreement) { | ||
| console.log( | ||
| `[INFO] Organization ${orgCode} is not on plan ${planCode}. Skipping metered usage update.` | ||
| ); | ||
| return; | ||
| } | ||
|
|
||
| const billingCustomerAgreementId = agreement.agreement_id; | ||
| console.log('[DEBUG] billingCustomerAgreementId:', billingCustomerAgreementId); | ||
|
|
||
| const billingFeatureCode = "user"; // Must match your metered feature key | ||
| console.log('[DEBUG] billingFeatureCode:', billingFeatureCode); | ||
|
|
||
| // Update metered usage for the organization (increment seat count) | ||
| console.log('[DEBUG] Posting metered usage update', { | ||
| customer_agreement_id: billingCustomerAgreementId, | ||
| billing_feature_code: billingFeatureCode, | ||
| meter_value: "1", | ||
| meter_type_code: "delta", | ||
| }); | ||
| const meterUsageResponse = await kindeAPI.post({ | ||
| endpoint: `billing/meter_usage`, | ||
| params: { | ||
| customer_agreement_id: billingCustomerAgreementId, | ||
| billing_feature_code: billingFeatureCode, | ||
| meter_value: "1", | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| meter_type_code: "delta", | ||
| }, | ||
| }); | ||
|
Comment on lines
+132
to
+140
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Unguarded async API call (meter usage I suggest to wrap this block in a
|
||
| console.log('[DEBUG] meterUsageResponse:', meterUsageResponse); | ||
|
|
||
| console.log( | ||
| `[INFO] Metered usage updated for organization ${orgCode} and user ${kindeUserId}` | ||
| ); | ||
| } | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Missing newline at end of file. The file ends without a trailing newline ( Fix: Add a single newline character after the closing |
||
Uh oh!
There was an error while loading. Please reload this page.