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
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
-- The Stripe subscription state the licence site reports for a paid Cloud
-- licence (trial end, cancelled at period end, renewal date, price), so the
-- app can tell the customer when their plan renews or ends. Nullable and
-- additive: the previous release ignores it during a blue/green switch.
ALTER TABLE "licenses" ADD COLUMN "billing" JSONB;
4 changes: 4 additions & 0 deletions packages/backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,10 @@ model License {
organizationId String? @map("organization_id")
organization Organization? @relation(fields: [organizationId], references: [id], onDelete: SetNull)
lastVerifiedAt DateTime? @map("last_verified_at")
/// The Stripe subscription's state as last reported by the licence site
/// (card trial end, cancelled at period end, next renewal, price). Cloud
/// only; null for trials, self-hosted and licences without a subscription.
billing Json?
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")

Expand Down
17 changes: 17 additions & 0 deletions packages/backend/src/license/license-checkout.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,23 @@ describe('LicenseController.checkoutLink', () => {
expect(licenseService.getCurrentLicense).toHaveBeenCalledWith('org-1');
});

it('passes a promotion code through, upper-cased, and omits it when absent', async () => {
const { controller, licenseService } = makeController();
await controller.checkoutLink(adminReq, {
plan: 'team',
billingPeriod: 'monthly',
trial: true,
promo: 'start30',
});
const [payload] = licenseService.createCheckoutIntent.mock.calls[0] as any[];
expect(payload.promoCode).toBe('START30');

const plain = makeController();
await plain.controller.checkoutLink(adminReq, { plan: 'team', billingPeriod: 'monthly', trial: true });
const [plainPayload] = plain.licenseService.createCheckoutIntent.mock.calls[0] as any[];
expect(plainPayload).not.toHaveProperty('promoCode');
});

it('asks to pay now when no trial is requested', async () => {
const { controller, licenseService } = makeController();
await controller.checkoutLink(adminReq, { plan: 'business', billingPeriod: 'monthly', trial: false });
Expand Down
2 changes: 2 additions & 0 deletions packages/backend/src/license/license-checkout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ export interface CheckoutIntentPayload {
trialEnd?: string;
returnUrl: string;
organizationId?: string;
/** A Stripe promotion code the user arrived with; the site pre-applies it. */
promoCode?: string;
adMetadata?: {
ad_consent: 'granted';
gclid?: string;
Expand Down
35 changes: 35 additions & 0 deletions packages/backend/src/license/license.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,12 +52,24 @@ class CheckoutLinkDto {

@IsBoolean()
trial: boolean;

// A Stripe promotion code the user arrived with (promo bar → sign-up). The
// licence site pre-applies it when it is a live code and ignores it if not.
@IsOptional()
@IsString()
@Matches(/^[A-Za-z0-9_-]{1,64}$/)
promo?: string;
}

class BillingPortalDto {
@IsOptional()
@IsString()
returnUrl?: string;

/** 'cancel': open Stripe's cancellation page directly. */
@IsOptional()
@IsIn(['cancel'])
flow?: 'cancel';
}

@ApiTags('License')
Expand Down Expand Up @@ -130,9 +142,30 @@ export class LicenseController {
lastVerifiedAt: license.lastVerifiedAt,
instanceId: license.instanceId,
...(trialDaysLeft !== undefined && { trialDaysLeft }),
// Cloud, paid licences: the subscription's state (card trial end, set
// to cancel at period end, next renewal and price).
...(license.billing && { billing: license.billing }),
};
}

@Post('refresh')
@HttpCode(200)
@UseGuards(AuthGuard('jwt'), RolesGuard)
@Roles('ADMIN')
@Throttle({ default: { limit: 5, ttl: 60_000 } })
@ApiBearerAuth()
@ApiOperation({
summary:
'Re-verify the workspace licence now, e.g. after returning from the billing portal (cloud, ADMIN)',
})
async refresh(@Req() req: any): Promise<{ refreshed: boolean }> {
if (!this.deployment.isCloud()) throw new NotFoundException();
const refreshed = await this.licenseService
.refreshLicense(req.user.organizationId)
.catch(() => false);
return { refreshed };
}

@Get('instance-id')
@ApiOperation({ summary: 'Get the instance ID' })
async getInstanceId() {
Expand Down Expand Up @@ -198,6 +231,7 @@ export class LicenseController {
return await this.licenseService.createBillingPortalSession(
req.user.organizationId,
dto?.returnUrl,
dto?.flow,
);
} catch (err: any) {
throw new BadRequestException(err.message || 'Failed to open billing portal');
Expand Down Expand Up @@ -262,6 +296,7 @@ export class LicenseController {
...(trialEnd && { trialEnd: trialEnd.toISOString() }),
returnUrl: `${cloudFrontendOrigin()}/settings/license/activate`,
organizationId,
...(dto.promo && { promoCode: dto.promo.toUpperCase() }),
...(adMetadata && { adMetadata }),
});
} catch (err: any) {
Expand Down
102 changes: 102 additions & 0 deletions packages/backend/src/license/license.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -403,3 +403,105 @@ describe('LicenseService — paid licences are re-verified', () => {
expect(prisma.license.findMany).not.toHaveBeenCalled();
});
});

describe('LicenseService — the subscription state reaches the app', () => {
// The app only knew "active" or not, so a card-trial customer never saw when
// the first charge comes, and a customer who cancelled never saw when the
// plan ends. The licence site now reports the subscription's state.
const axios = require('axios');
let get: jest.SpyInstance;
beforeEach(() => {
get = jest.spyOn(axios, 'get');
});
afterEach(() => get.mockRestore());

const BILLING = {
status: 'active',
cancelling: true,
endsAt: '2026-11-01T00:00:00.000Z',
currentPeriodEnd: '2026-11-01T00:00:00.000Z',
trialEnd: null,
amount: 4900,
currency: 'eur',
interval: 'month',
};

function make(isCloud = true) {
const updates: any[] = [];
const rows = [{ licenseKey: 'AMCP-EEEE-0000-0000-0005' }];
const prisma = {
license: {
findFirst: jest.fn().mockResolvedValue(rows[0]),
update: jest.fn(async (args: any) => {
updates.push(args);
return {};
}),
},
};
const deployment = { isCloud: () => isCloud };
const svc = new LicenseService(prisma as any, {} as any, deployment as any);
return { svc, prisma, updates };
}

it('asks the licence site for billing on cloud and stores it', async () => {
const { svc, updates } = make();
get.mockResolvedValueOnce({ data: { valid: true, plan: 'team', billing: BILLING } });
await svc.verifyLicense('AMCP-EEEE-0000-0000-0005');
expect(get.mock.calls[0][1].params).toEqual({ key: 'AMCP-EEEE-0000-0000-0005', billing: '1' });
expect(updates[0].data.billing).toEqual(BILLING);
});

it('does not ask for billing on self-hosted', async () => {
const { svc } = make(false);
get.mockResolvedValueOnce({ data: { valid: true, plan: 'team' } });
await svc.verifyLicense('AMCP-EEEE-0000-0000-0005');
expect(get.mock.calls[0][1].params).toEqual({ key: 'AMCP-EEEE-0000-0000-0005' });
});

it('stores nothing usable from a malformed billing block', async () => {
const { svc, updates } = make();
get.mockResolvedValueOnce({ data: { valid: true, plan: 'team', billing: { status: 42 } } });
await svc.verifyLicense('AMCP-EEEE-0000-0000-0005');
expect(updates[0].data.billing).not.toEqual(expect.objectContaining({ status: 42 }));
});

it('refreshes at most once a minute per workspace', async () => {
const { svc } = make();
get.mockResolvedValue({ data: { valid: true, plan: 'team', billing: BILLING } });
await expect(svc.refreshLicense('org-1')).resolves.toBe(true);
await expect(svc.refreshLicense('org-1')).resolves.toBe(false);
await expect(svc.refreshLicense('org-2')).resolves.toBe(true);
expect(get).toHaveBeenCalledTimes(2);
});
});

describe('parseLicenseBilling', () => {
const { parseLicenseBilling } = require('./license.service');
it('keeps a well-formed block and drops junk fields', () => {
expect(
parseLicenseBilling({
status: 'trialing',
cancelling: false,
trialEnd: '2026-10-08T09:00:00.000Z',
endsAt: 'not a date',
amount: 1900,
currency: 'eur',
interval: 'fortnight',
extra: 'x',
}),
).toEqual({
status: 'trialing',
cancelling: false,
endsAt: null,
currentPeriodEnd: null,
trialEnd: '2026-10-08T09:00:00.000Z',
amount: 1900,
currency: 'eur',
interval: null,
});
});
it('returns null without a status', () => {
expect(parseLicenseBilling({ cancelling: true })).toBeNull();
expect(parseLicenseBilling(null)).toBeNull();
});
});
91 changes: 89 additions & 2 deletions packages/backend/src/license/license.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { PrismaService } from '../common/prisma.service';
import { DeploymentService } from '../common/deployment.service';
import { SiteSettingsService } from '../settings/site-settings.service';
import { CheckoutIntentPayload, CheckoutUnavailableError } from './license-checkout';
import { Prisma } from '../generated/prisma/client';

// Production always talks to anythingmcp.com. The licence site decides
// which plan an installation runs; a URL taken from the environment would let
Expand All @@ -28,6 +29,24 @@ const trialRetryBaseMs = () => Number(process.env.TRIAL_RETRY_BASE_MS ?? 600);
/** A checkout link is asked for by someone waiting on a spinner: one retry, no more. */
const CHECKOUT_RETRY_ATTEMPTS = 2;

/**
* A paid Cloud licence's Stripe subscription as the licence site reports it
* (GET /api/license/verify?billing=1 with the service token). Dates are ISO
* strings, amount is in the currency's smallest unit.
*/
export interface LicenseBilling {
status: string;
/** Set to end at the period end (or a cancel_at date) instead of renewing. */
cancelling: boolean;
endsAt: string | null;
currentPeriodEnd: string | null;
/** End of a card trial, while trialing. */
trialEnd: string | null;
amount: number | null;
currency: string | null;
interval: 'day' | 'week' | 'month' | 'year' | null;
}

export interface LicenseInfo {
licenseKey: string;
plan: string;
Expand All @@ -36,6 +55,33 @@ export interface LicenseInfo {
expiresAt: Date | null;
lastVerifiedAt: Date | null;
instanceId: string | null;
billing?: LicenseBilling | null;
}

/** Shape check on what the licence site sent, so a bad payload stores nothing. */
export function parseLicenseBilling(raw: unknown): LicenseBilling | null {
if (!raw || typeof raw !== 'object') return null;
const b = raw as Record<string, unknown>;
const str = (v: unknown) => (typeof v === 'string' && v.length <= 64 ? v : null);
const date = (v: unknown) => {
const s = str(v);
return s && Number.isFinite(Date.parse(s)) ? s : null;
};
const status = str(b.status);
if (!status) return null;
const interval = ['day', 'week', 'month', 'year'].includes(b.interval as string)
? (b.interval as LicenseBilling['interval'])
: null;
return {
status,
cancelling: b.cancelling === true,
endsAt: date(b.endsAt),
currentPeriodEnd: date(b.currentPeriodEnd),
trialEnd: date(b.trialEnd),
amount: typeof b.amount === 'number' && Number.isFinite(b.amount) ? b.amount : null,
currency: str(b.currency),
interval,
};
}

export interface RemoteVerifyResponse {
Expand All @@ -48,6 +94,8 @@ export interface RemoteVerifyResponse {
paymentIssue?: boolean;
/** End of the payment grace period (also returned as expiresAt). */
graceUntil?: string;
/** The subscription's state (Cloud, service token, paid licences only). */
billing?: unknown;
}

@Injectable()
Expand Down Expand Up @@ -105,6 +153,8 @@ export class LicenseService implements OnModuleInit, OnModuleDestroy {
async createBillingPortalSession(
organizationId: string,
returnUrl?: string,
/** 'cancel' opens Stripe's cancellation page directly. */
flow?: 'cancel',
): Promise<{ url: string }> {
const license = await this.getCurrentLicense(organizationId);
if (!license?.licenseKey) {
Expand All @@ -117,7 +167,7 @@ export class LicenseService implements OnModuleInit, OnModuleDestroy {
// signed-in admin's own workspace.
const { data } = await axios.post(
`${this.apiBase}/api/billing/portal`,
{ licenseKey: license.licenseKey, returnUrl },
{ licenseKey: license.licenseKey, returnUrl, ...(flow && { flow }) },
{ timeout: 15000, headers: this.serviceHeaders() },
);
if (!data?.url) throw new Error('No portal URL returned.');
Expand Down Expand Up @@ -523,7 +573,13 @@ export class LicenseService implements OnModuleInit, OnModuleDestroy {
try {
const { data } = await axios.get<RemoteVerifyResponse>(
`${this.apiBase}/api/license/verify`,
{ params: { key: licenseKey }, timeout: 10000, headers: this.serviceHeaders() },
{
// Cloud also asks for the subscription's state (trial end, set to
// cancel, renewal date) so the app can tell the customer.
params: { key: licenseKey, ...(this.deployment.isCloud() && { billing: '1' }) },
timeout: 10000,
headers: this.serviceHeaders(),
},
);

// Update local record
Expand All @@ -538,6 +594,8 @@ export class LicenseService implements OnModuleInit, OnModuleDestroy {
? new Date(data.expiresAt)
: null;
updateData.status = 'active';
const billing = parseLicenseBilling(data.billing);
updateData.billing = billing ?? Prisma.DbNull;
} else {
updateData.status = data.error?.includes('revoked')
? 'revoked'
Expand Down Expand Up @@ -792,6 +850,35 @@ export class LicenseService implements OnModuleInit, OnModuleDestroy {
expiresAt: license.expiresAt,
lastVerifiedAt: license.lastVerifiedAt,
instanceId: license.instanceId,
billing: parseLicenseBilling(license.billing),
};
}

/** Last refresh per workspace, so the licence page cannot hammer the site. */
private readonly lastRefresh = new Map<string, number>();

/**
* Re-verify a workspace's licence now (Cloud, from the licence page), so a
* plan change, cancellation or card trial made in Stripe shows up right away
* instead of at the next daily re-verification. At most once a minute per
* workspace; a call inside that window does nothing.
*/
async refreshLicense(organizationId: string): Promise<boolean> {
if (!this.deployment.isCloud()) return false;
const last = this.lastRefresh.get(organizationId) ?? 0;
if (Date.now() - last < 60_000) return false;
this.lastRefresh.set(organizationId, Date.now());
if (this.lastRefresh.size > 5000) {
// Bounded: drop the oldest half rather than grow forever.
for (const k of [...this.lastRefresh.keys()].slice(0, 2500)) this.lastRefresh.delete(k);
}
const license = await this.prisma.license.findFirst({
where: { organizationId, status: 'active', plan: { not: 'trial' } },
orderBy: { createdAt: 'desc' },
select: { licenseKey: true },
});
if (!license) return false;
await this.verifyLicense(license.licenseKey);
return true;
}
}
Loading
Loading