Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
dd69287
docs(billing): design spec for tenant billing lifecycle
iammukeshm May 28, 2026
5845493
docs(billing): phase 1 implementation plan for tenant billing lifecycle
iammukeshm May 28, 2026
75d6e33
feat(billing): add billing interval + annual price to plans
iammukeshm May 28, 2026
19d862d
feat(billing): add invoice purpose + term period span
iammukeshm May 28, 2026
f904c3c
feat(billing): overage-only monthly job + subscription invoice service
iammukeshm May 28, 2026
b762d31
feat(billing): GetPlanTerm cross-module query
iammukeshm May 28, 2026
4994d96
feat(billing): handle tenant subscribed/renewed events
iammukeshm May 28, 2026
7525190
feat(billing): seed default plans (free, pro, pro-annual)
iammukeshm May 28, 2026
3f1ce6b
chore(db): migration for plan interval + invoice purpose
iammukeshm May 28, 2026
b28eefa
feat(billing): default-plan + grace-window options
iammukeshm May 28, 2026
98f70ee
feat(multitenancy): create tenant subscribes to a plan
iammukeshm May 28, 2026
7836a69
feat(multitenancy): plan-driven renew replaces explicit-date upgrade
iammukeshm May 28, 2026
af5120a
feat(multitenancy): enforce subscription expiry with a grace window
iammukeshm May 28, 2026
6b2469a
test(arch): allow Renew as an endpoint action verb
iammukeshm May 28, 2026
949b60f
fix(billing): collision-free invoice numbers + correct invoice-purpos…
iammukeshm May 28, 2026
05d0028
feat(admin): billing lifecycle UI — plan selector, intervals, renew
iammukeshm May 28, 2026
deeea70
test(admin): realign E2E specs with the reskinned dashboard UI
iammukeshm May 28, 2026
22bd689
feat(admin): show plan interval + invoice purpose in billing views
iammukeshm May 28, 2026
95ff777
feat(admin): modernize form dropdowns to the Radix dropdown
iammukeshm May 28, 2026
9da9d06
refactor(admin/billing): plan create/edit as a dialog
iammukeshm May 28, 2026
98b37f4
Merge branch 'main' into feat/tenant-billing-lifecycle
iammukeshm May 28, 2026
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
24 changes: 24 additions & 0 deletions clients/admin/src/api/billing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ export type SubscriptionStatus = "Active" | "Suspended" | "Cancelled" | (string

export type InvoiceLineItemKind = "BaseFee" | "Overage" | "Adjustment" | (string & {});

export type PlanInterval = "Monthly" | "Yearly" | (string & {});

export type InvoicePurpose = "Usage" | "Subscription" | (string & {});

export type QuotaResource =
| "ApiCalls"
| "StorageBytes"
Expand All @@ -26,6 +30,8 @@ export type BillingPlanDto = {
monthlyBasePrice: number;
overageRates: Partial<Record<QuotaResource, number>>;
isActive: boolean;
interval: PlanInterval;
annualPrice?: number | null;
};

export type CreatePlanInput = {
Expand All @@ -34,15 +40,26 @@ export type CreatePlanInput = {
currency: string;
monthlyBasePrice: number;
overageRates?: Partial<Record<QuotaResource, number>> | null;
interval?: PlanInterval;
annualPrice?: number | null;
};

export type UpdatePlanInput = {
planId: string;
name: string;
monthlyBasePrice: number;
overageRates?: Partial<Record<QuotaResource, number>> | null;
interval?: PlanInterval;
annualPrice?: number | null;
};

/** Price charged for one billing term: annual price (or 12x monthly) for yearly plans, else monthly. */
export function planTermPrice(plan: Pick<BillingPlanDto, "interval" | "monthlyBasePrice" | "annualPrice">): number {
return plan.interval === "Yearly"
? plan.annualPrice ?? plan.monthlyBasePrice * 12
: plan.monthlyBasePrice;
}

export function getPlans(includeInactive = false): Promise<BillingPlanDto[]> {
const query = new URLSearchParams({ includeInactive: includeInactive ? "true" : "false" });
return apiFetch<BillingPlanDto[]>(`/api/v1/billing/plans?${query.toString()}`);
Expand All @@ -57,6 +74,8 @@ export function createPlan(input: CreatePlanInput): Promise<string> {
currency: input.currency,
monthlyBasePrice: input.monthlyBasePrice,
overageRates: input.overageRates ?? null,
interval: input.interval ?? "Monthly",
annualPrice: input.annualPrice ?? null,
}),
});
}
Expand All @@ -69,6 +88,8 @@ export function updatePlan(input: UpdatePlanInput): Promise<string> {
name: input.name,
monthlyBasePrice: input.monthlyBasePrice,
overageRates: input.overageRates ?? null,
interval: input.interval ?? "Monthly",
annualPrice: input.annualPrice ?? null,
}),
});
}
Expand Down Expand Up @@ -132,6 +153,9 @@ export type InvoiceDto = {
voidedAtUtc?: string | null;
notes?: string | null;
lineItems: InvoiceLineItemDto[];
purpose: InvoicePurpose;
periodStartUtc?: string | null;
periodEndUtc?: string | null;
};

export type ListInvoicesParams = {
Expand Down
24 changes: 24 additions & 0 deletions clients/admin/src/api/tenants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,19 @@ import type { PagedResponse } from "@/lib/api-types";

export type { PagedResponse } from "@/lib/api-types";

export type TenantExpiryState = "Active" | "InGrace" | "Expired" | (string & {});

export type TenantDto = {
id: string;
name: string;
adminEmail: string;
isActive: boolean;
validUpto: string;
issuer?: string;
/** Present on the status endpoint (TenantStatusDto); absent on the list projection. */
plan?: string | null;
expiryState?: TenantExpiryState;
graceEndsUtc?: string;
};

export type ListTenantsParams = {
Expand All @@ -25,6 +31,15 @@ export type CreateTenantInput = {
adminPassword: string;
issuer: string;
connectionString?: string | null;
/** Plan key to subscribe the tenant to. Omitted → server falls back to the default/trial plan. */
planKey?: string | null;
};

export type RenewTenantResponse = {
tenantId: string;
validUpto: string;
planKey: string;
planChanged: boolean;
};

export type CreateTenantResponse = {
Expand Down Expand Up @@ -84,10 +99,19 @@ export async function createTenant(input: CreateTenantInput): Promise<CreateTena
adminPassword: input.adminPassword,
issuer: input.issuer,
connectionString: input.connectionString ?? null,
planKey: input.planKey ?? null,
}),
});
}

/** Renew a tenant for one more plan term, optionally switching plans. */
export async function renewTenant(id: string, planKey?: string | null): Promise<RenewTenantResponse> {
return apiFetch<RenewTenantResponse>(`/api/v1/tenants/${encodeURIComponent(id)}/renew`, {
method: "POST",
body: JSON.stringify({ tenantId: id, planKey: planKey ?? null }),
});
}

export async function changeTenantActivation(id: string, isActive: boolean): Promise<TenantLifecycleResult> {
return apiFetch<TenantLifecycleResult>(`/api/v1/tenants/${encodeURIComponent(id)}/activation`, {
method: "POST",
Expand Down
Loading
Loading