Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fresh-clubs-float.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clerk/backend': minor
---

Add `getOrganizationBillingSubscription` to BillingApi.
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,7 @@ exports[`Typedoc output > should have a deliberate file structure 1`] = `
"backend/commerce-subscription-item-webhook-event-json.mdx",
"backend/commerce-subscription-item.mdx",
"backend/commerce-subscription-webhook-event-json.mdx",
"backend/commerce-subscription.mdx",
"backend/email-address.mdx",
"backend/external-account.mdx",
"backend/get-auth-fn.mdx",
Expand Down
14 changes: 14 additions & 0 deletions packages/backend/src/api/endpoints/BillingApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@ import type { ClerkPaginationRequest } from '@clerk/types';

import { joinPaths } from '../../util/path';
import type { CommercePlan } from '../resources/CommercePlan';
import type { CommerceSubscription } from '../resources/CommerceSubscription';
import type { CommerceSubscriptionItem } from '../resources/CommerceSubscriptionItem';
import type { PaginatedResourceResponse } from '../resources/Deserializer';
import { AbstractAPI } from './AbstractApi';

const basePath = '/commerce';
const organizationBasePath = '/organizations';

type GetOrganizationListParams = ClerkPaginationRequest<{
payerType: 'org' | 'user';
Expand Down Expand Up @@ -45,4 +47,16 @@ export class BillingAPI extends AbstractAPI {
queryParams: params,
});
}

/**
* @experimental This is an experimental API for the Billing feature that is available under a public beta, and the API is subject to change.
* It is advised to pin the SDK version to avoid breaking changes.
*/
public async getOrganizationBillingSubscription(organizationId: string) {
this.requireId(organizationId);
return this.request<CommerceSubscription>({
method: 'GET',
path: joinPaths(organizationBasePath, organizationId, 'billing', 'subscription'),
});
}
Comment on lines +51 to +61
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

Add explicit return type and complete JSDoc for the public API

Per guidelines, public APIs should declare explicit return types and comprehensive JSDoc.

Apply this diff within the method block:

   /**
    * @experimental This is an experimental API for the Billing feature that is available under a public beta, and the API is subject to change.
    * It is advised to pin the SDK version to avoid breaking changes.
+   * @param organizationId - The target organization ID (e.g. "org_...").
+   * @returns The organization's billing subscription resource.
+   * @throws ClerkAPIResponseError If the organization does not exist or billing is not enabled.
    */
-  public async getOrganizationBillingSubscription(organizationId: string) {
+  public async getOrganizationBillingSubscription(organizationId: string): Promise<CommerceSubscription> {
     this.requireId(organizationId);
     return this.request<CommerceSubscription>({
       method: 'GET',
       path: joinPaths(organizationBasePath, organizationId, 'billing', 'subscription'),
     });
   }

I can add an integration test stub that mocks AbstractAPI.request to verify the shape and deserialization path—want me to push that?


🏁 Script executed:

#!/bin/bash
# Verify that the new method compiles to a Promise<CommerceSubscription> (textual check)
rg -nP "public\s+async\s+getOrganizationBillingSubscription\([^)]*\)\s*:\s*Promise<CommerceSubscription>" packages/backend/src/api/endpoints/BillingApi.ts -n

# Ensure the endpoint path matches server route conventions elsewhere in the repo
rg -n "billing.*subscription" -C2 packages | sed -n '1,200p'

Length of output: 11395


Add explicit return type and complete JSDoc for getOrganizationBillingSubscription
Public SDK methods must declare explicit return types and comprehensive JSDoc for parameters, return values, and thrown errors per our API guidelines.

– In packages/backend/src/api/endpoints/BillingApi.ts, update the method signature:

   /**
    * @experimental This is an experimental API for the Billing feature that is available under a public beta, and the API is subject to change.
    * It is advised to pin the SDK version to avoid breaking changes.
+   * @param organizationId – The target organization’s ID (e.g. "org_…").
+   * @returns The organization’s billing subscription resource.
+   * @throws ClerkAPIResponseError If the organization does not exist or billing is not enabled.
    */
-  public async getOrganizationBillingSubscription(organizationId: string) {
+  public async getOrganizationBillingSubscription(
+    organizationId: string
+  ): Promise<CommerceSubscription> {
     this.requireId(organizationId);
     return this.request<CommerceSubscription>({
       method: 'GET',
       path: joinPaths(organizationBasePath, organizationId, 'billing', 'subscription'),
     });
   }

• Please also verify that the billing/subscription endpoint path aligns exactly with our server’s API specification—​I wasn’t able to locate a matching route definition in the repo.
• Let me know if you’d like me to add an integration‐test stub that mocks AbstractAPI.request to validate the response shape and deserialization path.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
/**
* @experimental This is an experimental API for the Billing feature that is available under a public beta, and the API is subject to change.
* It is advised to pin the SDK version to avoid breaking changes.
*/
public async getOrganizationBillingSubscription(organizationId: string) {
this.requireId(organizationId);
return this.request<CommerceSubscription>({
method: 'GET',
path: joinPaths(organizationBasePath, organizationId, 'billing', 'subscription'),
});
}
/**
* @experimental This is an experimental API for the Billing feature that is available under a public beta, and the API is subject to change.
* It is advised to pin the SDK version to avoid breaking changes.
* @param organizationId The target organization’s ID (e.g. "org_…").
* @returns The organization’s billing subscription resource.
* @throws ClerkAPIResponseError If the organization does not exist or billing is not enabled.
*/
public async getOrganizationBillingSubscription(
organizationId: string
): Promise<CommerceSubscription> {
this.requireId(organizationId);
return this.request<CommerceSubscription>({
method: 'GET',
path: joinPaths(organizationBasePath, organizationId, 'billing', 'subscription'),
});
}
🤖 Prompt for AI Agents
In packages/backend/src/api/endpoints/BillingApi.ts around lines 51-61, the
public method getOrganizationBillingSubscription lacks an explicit return type
and complete JSDoc; update the method signature to explicitly return
Promise<CommerceSubscription> and add a JSDoc block that documents the
organizationId parameter, the Promise<CommerceSubscription> return value, and
any errors that can be thrown (e.g., validation errors from requireId and
network/request errors from AbstractAPI.request). While changing the
signature/JSDoc, verify the endpoint path "billing/subscription" against the
server route definitions (search the repo for matching routes) and correct the
joinPaths call if the server uses a different path (e.g.,
"billing/subscriptions" or a nested route); if you change the path, mirror that
in the JSDoc. Finally, run unit/integration tests or add a small test stub that
mocks AbstractAPI.request to assert the response deserializes to
CommerceSubscription.

}
79 changes: 79 additions & 0 deletions packages/backend/src/api/resources/CommerceSubscription.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { type CommerceMoneyAmount } from './CommercePlan';
import { CommerceSubscriptionItem } from './CommerceSubscriptionItem';
import type { CommerceSubscriptionJSON } from './JSON';

/**
* @experimental This is an experimental API for the Billing feature that is available under a public beta, and the API is subject to change.
* It is advised to pin the SDK version to avoid breaking changes.
*/
export class CommerceSubscription {
constructor(
/**
* The unique identifier for the commerce subscription.
*/
readonly id: string,
/**
* The current status of the subscription.
*/
readonly status: CommerceSubscriptionJSON['status'],
/**
* The ID of the payer for this subscription.
*/
readonly payerId: string,
/**
* Unix timestamp (milliseconds) of creation.
*/
readonly createdAt: number,
/**
* Unix timestamp (milliseconds) of last update.
*/
readonly updatedAt: number,
/**
* Unix timestamp (milliseconds) when the subscription became active.
*/
readonly activeAt: number | null,
/**
* Unix timestamp (milliseconds) when the subscription became past due.
*/
readonly pastDueAt: number | null,
/**
* Array of subscription items in this subscription.
*/
readonly subscriptionItems: CommerceSubscriptionItem[],
/**
* Information about the next scheduled payment.
*/
readonly nextPayment: { date: number; amount: CommerceMoneyAmount } | null,
/**
* Whether the payer is eligible for a free trial.
*/
readonly eligibleForFreeTrial: boolean,
) {}

static fromJSON(data: CommerceSubscriptionJSON): CommerceSubscription {
const nextPayment = data.next_payment
? {
date: data.next_payment.date,
amount: {
amount: data.next_payment.amount.amount,
amountFormatted: data.next_payment.amount.amount_formatted,
currency: data.next_payment.amount.currency,
currencySymbol: data.next_payment.amount.currency_symbol,
},
}
: null;

return new CommerceSubscription(
data.id,
data.status,
data.payer_id,
data.created_at,
data.updated_at,
data.active_at ?? null,
data.past_due_at ?? null,
data.subscription_items.map(item => CommerceSubscriptionItem.fromJSON(item)),
nextPayment,
data.eligible_for_free_trial ?? false,
);
}
}
34 changes: 28 additions & 6 deletions packages/backend/src/api/resources/CommerceSubscriptionItem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,28 +44,45 @@ export class CommerceSubscriptionItem {
* The plan ID.
*/
readonly planId: string,
/**
* The date and time the subscription item was created.
*/
readonly createdAt: number,
/**
* The date and time the subscription item was last updated.
*/
readonly updatedAt: number,
/**
* The end of the current period.
*/
readonly periodEnd?: number,
readonly periodEnd: number | null,
/**
* When the subscription item was canceled.
*/
readonly canceledAt?: number,
readonly canceledAt: number | null,
/**
* When the subscription item became past due.
*/
readonly pastDueAt?: number,
readonly pastDueAt: number | null,
/**
* When the subscription item ended.
*/
readonly endedAt: number | null,
/**
* The payer ID.
*/
readonly payerId: string,
/**
* Whether this subscription item is currently in a free trial period.
*/
readonly isFreeTrial?: boolean,
/**
* The lifetime amount paid for this subscription item.
*/
readonly lifetimePaid?: CommerceMoneyAmount | null,
) {}

static fromJSON(data: CommerceSubscriptionItemJSON): CommerceSubscriptionItem {
function formatAmountJSON(
amount: CommerceMoneyAmountJSON | null | undefined,
): CommerceMoneyAmount | null | undefined;
function formatAmountJSON(
amount: CommerceMoneyAmountJSON | null | undefined,
): CommerceMoneyAmount | null | undefined {
Expand All @@ -90,9 +107,14 @@ export class CommerceSubscriptionItem {
formatAmountJSON(data.amount),
CommercePlan.fromJSON(data.plan),
data.plan_id,
data.created_at,
data.updated_at,
data.period_end,
data.canceled_at,
data.past_due_at,
data.ended_at,
data.payer_id,
data.is_free_trial,
formatAmountJSON(data.lifetime_paid),
);
}
Expand Down
3 changes: 3 additions & 0 deletions packages/backend/src/api/resources/Deserializer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import {
} from '.';
import { AccountlessApplication } from './AccountlessApplication';
import { CommercePlan } from './CommercePlan';
import { CommerceSubscription } from './CommerceSubscription';
import { CommerceSubscriptionItem } from './CommerceSubscriptionItem';
import { Feature } from './Feature';
import type { PaginatedResponseJSON } from './JSON';
Expand Down Expand Up @@ -184,6 +185,8 @@ function jsonToObject(item: any): any {
return WaitlistEntry.fromJSON(item);
case ObjectType.CommercePlan:
return CommercePlan.fromJSON(item);
case ObjectType.CommerceSubscription:
return CommerceSubscription.fromJSON(item);
case ObjectType.CommerceSubscriptionItem:
return CommerceSubscriptionItem.fromJSON(item);
case ObjectType.Feature:
Expand Down
27 changes: 24 additions & 3 deletions packages/backend/src/api/resources/JSON.ts
Original file line number Diff line number Diff line change
Expand Up @@ -861,10 +861,15 @@ export interface CommerceSubscriptionItemJSON extends ClerkResourceJSON {
object: typeof ObjectType.CommerceSubscriptionItem;
status: CommerceSubscriptionItemStatus;
plan_period: 'month' | 'annual';
payer_id: string;
period_start: number;
period_end?: number;
canceled_at?: number;
past_due_at?: number;
period_end: number | null;
is_free_trial?: boolean;
ended_at: number | null;
created_at: number;
updated_at: number;
canceled_at: number | null;
past_due_at: number | null;
lifetime_paid: CommerceMoneyAmountJSON;
next_payment: {
amount: number;
Expand Down Expand Up @@ -972,6 +977,22 @@ export interface CommerceSubscriptionWebhookEventJSON extends ClerkResourceJSON
items: CommerceSubscriptionItemWebhookEventJSON[];
}

export interface CommerceSubscriptionJSON extends ClerkResourceJSON {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Add comprehensive JSDoc for the new public API

Per repo guidelines, all public APIs need JSDoc. Add the experimental notice and field-level docs for payer_id, subscription_items, and next_payment.

Apply this diff:

- export interface CommerceSubscriptionJSON extends ClerkResourceJSON {
+ /**
+  * @experimental This is an experimental API for the Billing feature that is available under a public beta,
+  * and the API is subject to change. Pin the SDK version to avoid breaking changes.
+  *
+  * Data model returned by `BillingApi.getOrganizationBillingSubscription`.
+  * - `subscription_items`: list of items in this subscription.
+  * - `next_payment`: next scheduled charge for the subscription, or null if none.
+  */
+ export interface CommerceSubscriptionJSON extends ClerkResourceJSON {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export interface CommerceSubscriptionJSON extends ClerkResourceJSON {
/**
* @experimental This is an experimental API for the Billing feature that is available under a public beta,
* and the API is subject to change. Pin the SDK version to avoid breaking changes.
*
* Data model returned by `BillingApi.getOrganizationBillingSubscription`.
* - `subscription_items`: list of items in this subscription.
* - `next_payment`: next scheduled charge for the subscription, or null if none.
*/
export interface CommerceSubscriptionJSON extends ClerkResourceJSON {
🤖 Prompt for AI Agents
In packages/backend/src/api/resources/JSON.ts around line 980, add a
comprehensive JSDoc comment immediately above the exported interface
CommerceSubscriptionJSON: mark the API as experimental, document the interface
purpose, and add field-level @property tags describing payer_id (the unique
identifier for the payer, format and nullable/optional status),
subscription_items (array shape, item type and meaning), and next_payment (next
payment date/time or object shape and nullable/optional status). Ensure the
JSDoc follows repo conventions, uses @experimental or an explicit experimental
notice, and documents types/optionality for each field so the public API is
fully documented.

object: typeof ObjectType.CommerceSubscription;
status: 'active' | 'past_due' | 'canceled' | 'ended' | 'abandoned' | 'incomplete';
payer_id: string;
created_at: number;
updated_at: number;
active_at: number | null;
past_due_at: number | null;
subscription_items: CommerceSubscriptionItemJSON[];
next_payment?: {
date: number;
amount: CommerceMoneyAmountJSON;
};
eligible_for_free_trial?: boolean;
}
Comment on lines +980 to +994
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

Public contract mismatch with packages/types: high risk of consumer breakage

This CommerceSubscriptionJSON differs from packages/types/src/json.ts in multiple ways:

  • status union includes terminal/internal states; SDK restricts to 'active' | 'past_due'
  • payer_id exists here; SDK has no top-level payer_id
  • active_at is nullable here; SDK requires it
  • updated_at is non-null here; SDK allows null
  • subscription_items is non-null array here; SDK allows null
  • next_payment optional shape matches, fine

Two safe paths:

Option A — Align with SDK shape (preferred for a public surface):

 export interface CommerceSubscriptionJSON extends ClerkResourceJSON {
   object: typeof ObjectType.CommerceSubscription;
-  status: 'active' | 'past_due' | 'canceled' | 'ended' | 'abandoned' | 'incomplete';
-  payer_id: string;
+  status: 'active' | 'past_due';
   created_at: number;
-  updated_at: number;
-  active_at: number | null;
+  active_at: number;
+  updated_at: number | null;
   past_due_at: number | null;
-  subscription_items: CommerceSubscriptionItemJSON[];
+  subscription_items: CommerceSubscriptionItemJSON[] | null;
   next_payment?: {
     date: number;
     amount: CommerceMoneyAmountJSON;
   };
   eligible_for_free_trial?: boolean;
 }

Additionally, update CommerceSubscription.fromJSON to handle possibly-null subscription_items and nullable updated_at; and drop payerId unless it exists at another level.

Option B — Keep extended backend shape but scope it:

  • Rename to InternalCommerceSubscriptionJSON (or add @internal) and avoid exporting it via public index files.
  • Add a mapper that produces the SDK-aligned shape for anything exposed publicly.
  • Ensure BillingApi.getOrganizationBillingSubscription returns the class CommerceSubscription (stable surface), not this extended JSON.

Given the scope of impact, please choose one path before merging. I can provide a follow-up patch for either approach, including the mapper changes in CommerceSubscription.ts.


🏁 Script executed:

#!/bin/bash
set -e

# Locate json.ts in packages/types/src
json_file=$(fd -t f json.ts packages/types/src | head -n1)
echo "Using file: $json_file"

# Show the CommerceSubscriptionJSON interface and its key fields
rg -n 'interface CommerceSubscriptionJSON' -C10 "$json_file"
rg -n 'status' -C2 "$json_file"
rg -n 'payer_id' -C2 "$json_file"
rg -n 'active_at' -C2 "$json_file"
rg -n 'updated_at' -C2 "$json_file"
rg -n 'subscription_items' -C2 "$json_file"

Length of output: 4023


🏁 Script executed:

#!/bin/bash
set -e

types_file="packages/types/src/json.ts"
echo "SDK JSON File: $types_file"

# Extract the CommerceSubscriptionJSON block
start=$(rg -n "export interface CommerceSubscriptionJSON" -n "$types_file" | cut -d: -f1)
# Capture through the matching closing brace (assuming no nested braces inside)
relative_end=$(tail -n +$start "$types_file" | sed -n '1,/^}/p' | tail -n1 | grep -n '^}' | cut -d: -f1)
end=$((start + relative_end - 1))
echo "--- CommerceSubscriptionJSON Definition ---"
sed -n "${start},${end}p" "$types_file"

echo
echo "--- Key Property Definitions ---"
echo "status:"
rg -n "status:" -C2 "$types_file" | sed -n '1,5p'
echo
echo "updated_at:"
rg -n "updated_at:" -C2 "$types_file" | sed -n '1,5p'
echo
echo "subscription_items:"
rg -n "subscription_items:" -C2 "$types_file" | sed -n '1,5p'

Length of output: 1458


🏁 Script executed:

#!/bin/bash
set -e

# Print lines 970-1040 of the backend JSON file to inspect CommerceSubscriptionJSON
sed -n '970,1040p' packages/backend/src/api/resources/JSON.ts

Length of output: 857


Public JSON contract mismatch in CommerceSubscriptionJSON (packages/backend/src/api/resources/JSON.ts)

The CommerceSubscriptionJSON defined in the backend package diverges from the SDK’s CommerceSubscriptionJSON in packages/types/src/json.ts. Left unaddressed, these differences will break downstream consumers:

status
– Backend:
'active' | 'past_due' | 'canceled' | 'ended' | 'abandoned' | 'incomplete'
– SDK:
Extract<CommerceSubscriptionStatus, 'active' | 'past_due'> (only 'active' or 'past_due')
payer_id
– Backend: payer_id: string present at top level
– SDK: no payer_id field
active_at
– Backend: active_at: number | null
– SDK: active_at: number (non-nullable)
updated_at
– Backend: updated_at: number (non-nullable)
– SDK: updated_at: number | null
subscription_items
– Backend: subscription_items: CommerceSubscriptionItemJSON[] (non-nullable)
– SDK: subscription_items: CommerceSubscriptionItemJSON[] | null

Two paths forward:

  1. Align backend JSON with SDK public shape (preferred for stability)
    – Restrict status to 'active' | 'past_due'
    – Remove top-level payer_id
    – Make active_at: number (non-nullable) and updated_at: number | null
    – Allow subscription_items: CommerceSubscriptionItemJSON[] | null
    – Update CommerceSubscription.fromJSON to handle nullable fields and drop payerId

  2. Scope extended backend shape as internal
    – Rename to InternalCommerceSubscriptionJSON (or mark @internal) and don’t re-export it publicly
    – Add a mapper in CommerceSubscription.ts that converts the internal JSON into the SDK’s public shape
    – Ensure public methods (e.g. BillingApi.getOrganizationBillingSubscription) return the public CommerceSubscription

Given the high risk of breaking changes, please choose one approach before merging. I can follow up with a patch for either path.


export interface WebhooksSvixJSON {
svix_url: string;
}
1 change: 1 addition & 0 deletions packages/backend/src/api/resources/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ export * from './Verification';
export * from './WaitlistEntry';
export * from './Web3Wallet';
export * from './CommercePlan';
export * from './CommerceSubscription';
export * from './CommerceSubscriptionItem';

export type {
Expand Down
2 changes: 2 additions & 0 deletions packages/backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ export type {
TestingTokenJSON,
WebhooksSvixJSON,
CommercePlanJSON,
CommerceSubscriptionJSON,
CommerceSubscriptionItemJSON,
} from './api/resources/JSON';

Expand Down Expand Up @@ -144,6 +145,7 @@ export type {
User,
TestingToken,
CommercePlan,
CommerceSubscription,
CommerceSubscriptionItem,
} from './api/resources';

Expand Down
Loading