Some checks failed
App Prod-Parity E2E Dispatch / dispatch (push) Failing after 10s
CD Storybook UI / build (push) Failing after 3m25s
CD Storybook UI / dispatch (push) Has been skipped
CD deploy main / website-changed-files (push) Successful in 18m8s
CI Agent Skills / changed-files-check (push) Successful in 18m9s
CI Create App E2E minimal / changed-files-check (push) Successful in 24s
CI Create App / changed-files-check (push) Successful in 26s
CI Docs / changed-files-check (push) Successful in 26s
CI Emails / changed-files-check (push) Successful in 28s
CI Example App Hello World / changed-files-check (push) Successful in 23s
CI Front / changed-files-check (push) Has been skipped
CI Example App Postcard / changed-files-check (push) Successful in 27s
CI UI / changed-files-check (push) Has been skipped
CI Utils Test / changed-files-check (push) Successful in 45s
CI Twenty Apps / discover (push) Successful in 1m3s
CD deploy main / deploy-main (push) Failing after 3s
CI Agent Skills / agent-skills-validate (push) Has been skipped
CI Create App / create-app-test (lint) (push) Has been skipped
CI Emails / emails-test (push) Has been skipped
CI Front / front-task (lingui:compile) (push) Has been skipped
CI Front / front-task (lint) (push) Has been skipped
CI Example App Postcard / example-app-postcard (push) Has been skipped
CI UI / ui-task (lint) (push) Has been skipped
CI UI / ui-task (pack-size) (push) Has been skipped
CI UI / ui-task (size) (push) Has been skipped
CI UI / ui-task (test) (push) Has been skipped
CI UI / ui-task (typecheck) (push) Has been skipped
CI Utils Test / utils-test (push) Has been skipped
CI Twenty Apps / ${{ matrix.app.name }} (push) Has been skipped
CI Emails / ci-emails-status-check (push) Successful in 2s
CI Example App Hello World / ci-example-app-hello-world-status-check (push) Successful in 2s
CI Example App Postcard / ci-example-app-postcard-status-check (push) Successful in 3s
CI UI / ui-sb-test (push) Has been skipped
CI Utils Test / ci-utils-test-status-check (push) Successful in 2s
CI Twenty Apps / ci-twenty-apps-status-check (push) Successful in 2s
CI UI / ci-ui-status-check (push) Successful in 2s
CI Desktop / desktop (push) Failing after 1m8s
Push docs to Crowdin / Push documentation to Crowdin (push) Failing after 55s
Push translations to Crowdin / Extract and upload translations (push) Failing after 5s
Push website translations to Crowdin / Extract and upload website translations (push) Failing after 5s
CI Create App / create-app-test (test) (push) Has been skipped
CI Create App / create-app-test (typecheck) (push) Has been skipped
CI Docs / docs-lint (push) Has been skipped
CI Example App Hello World / example-app-hello-world (push) Has been skipped
CI Front / front-task (lingui:extract) (push) Has been skipped
CI Front / front-task (test) (push) Has been skipped
CI Front / front-task (typecheck) (push) Has been skipped
CI UI / ui-sb-build (push) Failing after 1m32s
CI Twenty Apps / ${{ matrix.app.name }} (${{ matrix.server-source }}) (push) Has been skipped
CI Agent Skills / agent-skills-publish (push) Has been skipped
CI Create App / ci-create-app-status-check (push) Successful in 2s
CI Agent Skills / agent-skills-remote-source (push) Has been skipped
CI Create App E2E minimal / create-app-e2e-minimal (push) Has been cancelled
CI E2E Main / e2e-test (push) Has been cancelled
CI Front / front-sb-build (push) Has been cancelled
CI Front / front-build (push) Has been cancelled
CI Create App E2E minimal / ci-create-app-e2e-minimal-status-check (push) Has been cancelled
CI E2E Main / ci-e2e-main-status-check (push) Has been cancelled
CI E2E Main / notify-main-ci-failure (push) Has been cancelled
CI E2E Main / qa-scout-comment (push) Has been cancelled
CI Front / front-sb-test (push) Has been cancelled
CI Front / front-sb-screenshots (push) Has been cancelled
CI Front / ci-front-status-check (push) Has been cancelled
DPA Sub-Processor Sync / sync-subprocessors (push) Failing after 3s
AI Catalog Sync / sync-catalog (push) Failing after 2s
414 lines
12 KiB
TypeScript
414 lines
12 KiB
TypeScript
import { INTERNAL_CREDITS_PER_DISPLAY_CREDIT } from 'twenty-shared/constants';
|
|
import { randomUUID } from 'node:crypto';
|
|
|
|
import { addDays, addMonths, startOfMonth } from 'date-fns';
|
|
import request from 'supertest';
|
|
import {
|
|
getBillingUsageCacheService,
|
|
getSeededBillingWorkspaceId,
|
|
listCreditGrants,
|
|
quitBillingFixtureRedis,
|
|
readAllowanceCounter,
|
|
resetBillingCreditState,
|
|
setupResourceCreditSubscription,
|
|
warmAllowanceCounter,
|
|
setSubscriptionStatus,
|
|
} from 'test/integration/billing/utils/billing-credit-fixtures.util';
|
|
|
|
import { BillingCreditGrantType } from 'src/engine/core-modules/billing/enums/billing-credit-grant-type.enum';
|
|
import { SubscriptionInterval } from 'src/engine/core-modules/billing/enums/billing-subscription-interval.enum';
|
|
import { SubscriptionStatus } from 'src/engine/core-modules/billing/enums/billing-subscription-status.enum';
|
|
import { alignGrantExpiryToPeriodEnd } from 'src/engine/core-modules/billing/utils/align-grant-expiry-to-period-end.util';
|
|
|
|
const client = request(`http://localhost:${APP_PORT}`);
|
|
|
|
const PERIOD_START = startOfMonth(new Date());
|
|
const PERIOD_END = addMonths(PERIOD_START, 1);
|
|
const ALLOWANCE_MICRO = 1_000_000;
|
|
|
|
const GRANT_MUTATION = `
|
|
mutation GrantWorkspaceCredits(
|
|
$workspaceId: UUID!
|
|
$amount: Float!
|
|
$type: BillingCreditGrantType!
|
|
$reason: String
|
|
$expiresInDays: Int
|
|
$clientOperationId: UUID!
|
|
) {
|
|
grantWorkspaceCredits(
|
|
workspaceId: $workspaceId
|
|
amount: $amount
|
|
type: $type
|
|
reason: $reason
|
|
expiresInDays: $expiresInDays
|
|
clientOperationId: $clientOperationId
|
|
) {
|
|
id
|
|
amount
|
|
type
|
|
reason
|
|
expiresAt
|
|
isActive
|
|
}
|
|
}
|
|
`;
|
|
|
|
const REVOKE_MUTATION = `
|
|
mutation RevokeWorkspaceCreditGrant($workspaceId: UUID!, $creditGrantId: UUID!) {
|
|
revokeWorkspaceCreditGrant(
|
|
workspaceId: $workspaceId
|
|
creditGrantId: $creditGrantId
|
|
) {
|
|
id
|
|
revokedAt
|
|
}
|
|
}
|
|
`;
|
|
|
|
const grantCredits = (variables: Record<string, unknown>) =>
|
|
callAdminGraphql(GRANT_MUTATION, {
|
|
clientOperationId: randomUUID(),
|
|
...variables,
|
|
});
|
|
|
|
const callAdminGraphql = (query: string, variables: Record<string, unknown>) =>
|
|
client
|
|
.post('/admin-panel')
|
|
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
|
|
.set('Content-Type', 'application/json')
|
|
.send({ query, variables });
|
|
|
|
describe('Admin credit grant and revoke (integration)', () => {
|
|
let workspaceId: string;
|
|
|
|
beforeAll(async () => {
|
|
workspaceId = await getSeededBillingWorkspaceId();
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
await resetBillingCreditState(workspaceId);
|
|
await setupResourceCreditSubscription({
|
|
workspaceId,
|
|
periodStart: PERIOD_START,
|
|
periodEnd: PERIOD_END,
|
|
creditAmountMicro: ALLOWANCE_MICRO,
|
|
});
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await resetBillingCreditState(workspaceId);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await quitBillingFixtureRedis();
|
|
});
|
|
|
|
it('records a granted amount on the ledger and mirrors it', async () => {
|
|
const response = await grantCredits({
|
|
workspaceId,
|
|
amount: 2,
|
|
type: BillingCreditGrantType.COMPENSATION,
|
|
reason: 'Outage on the 3rd',
|
|
});
|
|
|
|
expect(response.body.errors).toBeUndefined();
|
|
|
|
const grants = await listCreditGrants(workspaceId);
|
|
|
|
expect(grants).toHaveLength(1);
|
|
expect(grants[0]).toMatchObject({
|
|
amountMicro: 2 * INTERNAL_CREDITS_PER_DISPLAY_CREDIT,
|
|
type: BillingCreditGrantType.COMPENSATION,
|
|
reason: 'Outage on the 3rd',
|
|
revokedAt: null,
|
|
});
|
|
});
|
|
|
|
it('leaves a granted amount without an expiry so no missed transition can drop it', async () => {
|
|
const response = await grantCredits({
|
|
workspaceId,
|
|
amount: 2,
|
|
type: BillingCreditGrantType.COMPENSATION,
|
|
reason: null,
|
|
});
|
|
|
|
expect(response.body.errors).toBeUndefined();
|
|
expect(response.body.data.grantWorkspaceCredits.expiresAt).toBeNull();
|
|
|
|
const grants = await listCreditGrants(workspaceId);
|
|
|
|
expect(grants[0].expiresAt).toBeNull();
|
|
});
|
|
|
|
// Rounded up to a period end rather than the exact day: credits are spent and
|
|
// settled a period at a time, so a deadline inside one would be invisible to
|
|
// both the cached counter and the carry-forward.
|
|
it('expires a time-boxed grant at the end of the period the requested day falls in', async () => {
|
|
const response = await grantCredits({
|
|
workspaceId,
|
|
amount: 2,
|
|
type: BillingCreditGrantType.SALES,
|
|
reason: 'Pilot credits',
|
|
expiresInDays: 30,
|
|
});
|
|
|
|
expect(response.body.errors).toBeUndefined();
|
|
expect(response.body.data.grantWorkspaceCredits.isActive).toBe(true);
|
|
|
|
const [storedGrant] = await listCreditGrants(workspaceId);
|
|
|
|
// Which period the thirtieth day lands in depends on when the suite runs,
|
|
// so the boundary is derived the same way the server derives it rather
|
|
// than listed.
|
|
const expectedExpiresAt = alignGrantExpiryToPeriodEnd({
|
|
requestedExpiresAt: addDays(new Date(), 30),
|
|
currentPeriodStart: PERIOD_START,
|
|
currentPeriodEnd: PERIOD_END,
|
|
interval: SubscriptionInterval.Month,
|
|
});
|
|
|
|
expect(storedGrant.expiresAt).toEqual(expectedExpiresAt);
|
|
expect(expectedExpiresAt.getTime()).toBeGreaterThanOrEqual(
|
|
addDays(new Date(), 30).getTime(),
|
|
);
|
|
});
|
|
|
|
it('refuses an expiry beyond the accepted range', async () => {
|
|
const response = await grantCredits({
|
|
workspaceId,
|
|
amount: 2,
|
|
type: BillingCreditGrantType.COMPENSATION,
|
|
reason: null,
|
|
expiresInDays: 100_000,
|
|
});
|
|
|
|
expect(response.body.errors).toBeDefined();
|
|
expect(await listCreditGrants(workspaceId)).toHaveLength(0);
|
|
});
|
|
|
|
it('adds the granted amount to a warm available-credits counter', async () => {
|
|
const cache = getBillingUsageCacheService();
|
|
|
|
await cache.warmAvailableCredits(
|
|
workspaceId,
|
|
PERIOD_START,
|
|
PERIOD_END,
|
|
500_000,
|
|
);
|
|
|
|
await grantCredits({
|
|
workspaceId,
|
|
amount: 2,
|
|
type: BillingCreditGrantType.COMPENSATION,
|
|
reason: null,
|
|
});
|
|
|
|
expect(await cache.getAvailableCredits(workspaceId, PERIOD_START)).toBe(
|
|
500_000 + 2 * INTERNAL_CREDITS_PER_DISPLAY_CREDIT,
|
|
);
|
|
});
|
|
|
|
// The aligned deadline lands on the period end the counter already expires
|
|
// with, so the cached balance stays valid and can simply be incremented.
|
|
it('keeps a warm available-credits counter usable for a time-boxed grant', async () => {
|
|
const cache = getBillingUsageCacheService();
|
|
|
|
await cache.warmAvailableCredits(
|
|
workspaceId,
|
|
PERIOD_START,
|
|
PERIOD_END,
|
|
500_000,
|
|
);
|
|
|
|
await grantCredits({
|
|
workspaceId,
|
|
amount: 2,
|
|
type: BillingCreditGrantType.SALES,
|
|
reason: null,
|
|
expiresInDays: 1,
|
|
});
|
|
|
|
expect(await cache.getAvailableCredits(workspaceId, PERIOD_START)).toBe(
|
|
500_000 + 2 * INTERNAL_CREDITS_PER_DISPLAY_CREDIT,
|
|
);
|
|
});
|
|
|
|
it('takes a revoked grant back off the ledger and the available-credits counter', async () => {
|
|
const cache = getBillingUsageCacheService();
|
|
|
|
await cache.warmAvailableCredits(
|
|
workspaceId,
|
|
PERIOD_START,
|
|
PERIOD_END,
|
|
500_000,
|
|
);
|
|
|
|
const granted = await grantCredits({
|
|
workspaceId,
|
|
amount: 2,
|
|
type: BillingCreditGrantType.COMPENSATION,
|
|
reason: null,
|
|
});
|
|
const creditGrantId = granted.body.data.grantWorkspaceCredits.id;
|
|
|
|
const revoked = await callAdminGraphql(REVOKE_MUTATION, {
|
|
workspaceId,
|
|
creditGrantId,
|
|
});
|
|
|
|
expect(revoked.body.errors).toBeUndefined();
|
|
expect(
|
|
revoked.body.data.revokeWorkspaceCreditGrant.revokedAt,
|
|
).not.toBeNull();
|
|
|
|
const grants = await listCreditGrants(workspaceId);
|
|
|
|
expect(grants[0].revokedAt).not.toBeNull();
|
|
expect(await cache.getAvailableCredits(workspaceId, PERIOD_START)).toBe(
|
|
500_000,
|
|
);
|
|
});
|
|
|
|
it.each([
|
|
BillingCreditGrantType.ROLLOVER,
|
|
BillingCreditGrantType.ONBOARDING_REWARD,
|
|
])('refuses to grant a %s by hand', async (type) => {
|
|
const response = await grantCredits({
|
|
workspaceId,
|
|
amount: 1,
|
|
type,
|
|
reason: null,
|
|
});
|
|
|
|
expect(response.body.errors?.[0]?.extensions?.subCode).toBe(
|
|
'BILLING_CREDIT_GRANT_TYPE_NOT_GRANTABLE',
|
|
);
|
|
expect(await listCreditGrants(workspaceId)).toHaveLength(0);
|
|
});
|
|
|
|
it('drops the warm allowance counter when a grant lands', async () => {
|
|
await warmAllowanceCounter(workspaceId, PERIOD_START, 500_000);
|
|
|
|
await grantCredits({
|
|
workspaceId,
|
|
amount: 2,
|
|
type: BillingCreditGrantType.COMPENSATION,
|
|
reason: null,
|
|
});
|
|
|
|
expect(await readAllowanceCounter(workspaceId, PERIOD_START)).toBeNull();
|
|
});
|
|
|
|
it('takes a revoked grant off the ledger and drops the counter', async () => {
|
|
const granted = await grantCredits({
|
|
workspaceId,
|
|
amount: 2,
|
|
type: BillingCreditGrantType.COMPENSATION,
|
|
reason: null,
|
|
});
|
|
const creditGrantId = granted.body.data.grantWorkspaceCredits.id;
|
|
|
|
await warmAllowanceCounter(workspaceId, PERIOD_START, 500_000);
|
|
|
|
const revoked = await callAdminGraphql(REVOKE_MUTATION, {
|
|
workspaceId,
|
|
creditGrantId,
|
|
});
|
|
|
|
expect(revoked.body.errors).toBeUndefined();
|
|
expect(
|
|
revoked.body.data.revokeWorkspaceCreditGrant.revokedAt,
|
|
).not.toBeNull();
|
|
|
|
const grants = await listCreditGrants(workspaceId);
|
|
|
|
expect(grants[0].revokedAt).not.toBeNull();
|
|
expect(await readAllowanceCounter(workspaceId, PERIOD_START)).toBeNull();
|
|
});
|
|
|
|
// The panel only offers the three operator types, but the mutation is
|
|
// reachable directly and these two are written by the period transition and
|
|
// the onboarding jobs.
|
|
it.each([
|
|
BillingCreditGrantType.ROLLOVER,
|
|
BillingCreditGrantType.ONBOARDING_REWARD,
|
|
])('refuses to grant a %s by hand', async (type) => {
|
|
const response = await grantCredits({
|
|
workspaceId,
|
|
amount: 1,
|
|
type,
|
|
reason: null,
|
|
});
|
|
|
|
expect(response.body.errors?.[0]?.extensions?.subCode).toBe(
|
|
'BILLING_CREDIT_GRANT_TYPE_NOT_GRANTABLE',
|
|
);
|
|
expect(await listCreditGrants(workspaceId)).toHaveLength(0);
|
|
});
|
|
|
|
it('refuses an amount above the configured ceiling', async () => {
|
|
const response = await grantCredits({
|
|
workspaceId,
|
|
amount: 1_000_000_000,
|
|
type: BillingCreditGrantType.COMPENSATION,
|
|
reason: null,
|
|
});
|
|
|
|
expect(response.body.errors?.[0]?.extensions?.subCode).toBe(
|
|
'BILLING_CREDIT_AMOUNT_INVALID',
|
|
);
|
|
expect(await listCreditGrants(workspaceId)).toHaveLength(0);
|
|
});
|
|
|
|
// The admin panel keeps one operation id per open modal, so an Apollo retry
|
|
// or a resubmit after a lost response must answer with the grant the first
|
|
// attempt wrote rather than crediting the workspace a second time.
|
|
it('answers a retried grant with the original instead of granting twice', async () => {
|
|
const clientOperationId = randomUUID();
|
|
const variables = {
|
|
workspaceId,
|
|
amount: 25,
|
|
type: BillingCreditGrantType.COMPENSATION,
|
|
reason: 'Retried by the client',
|
|
clientOperationId,
|
|
};
|
|
|
|
const first = await callAdminGraphql(GRANT_MUTATION, variables);
|
|
const second = await callAdminGraphql(GRANT_MUTATION, variables);
|
|
|
|
expect(second.body.data.grantWorkspaceCredits.id).toBe(
|
|
first.body.data.grantWorkspaceCredits.id,
|
|
);
|
|
expect(await listCreditGrants(workspaceId)).toHaveLength(1);
|
|
});
|
|
|
|
// The refusal to time-box a grant with no billing period must not reach a
|
|
// replay: the subscription that anchored the original deadline can be gone by
|
|
// the time the client retries, and the operation already succeeded.
|
|
it('answers a retried time-boxed grant after the subscription is canceled', async () => {
|
|
const clientOperationId = randomUUID();
|
|
const variables = {
|
|
workspaceId,
|
|
amount: 25,
|
|
type: BillingCreditGrantType.SALES,
|
|
reason: 'Retried after cancellation',
|
|
clientOperationId,
|
|
expiresInDays: 30,
|
|
};
|
|
|
|
const first = await callAdminGraphql(GRANT_MUTATION, variables);
|
|
|
|
expect(first.body.errors).toBeUndefined();
|
|
|
|
await setSubscriptionStatus(workspaceId, SubscriptionStatus.Canceled);
|
|
|
|
const second = await callAdminGraphql(GRANT_MUTATION, variables);
|
|
|
|
expect(second.body.errors).toBeUndefined();
|
|
expect(second.body.data.grantWorkspaceCredits.id).toBe(
|
|
first.body.data.grantWorkspaceCredits.id,
|
|
);
|
|
expect(await listCreditGrants(workspaceId)).toHaveLength(1);
|
|
});
|
|
});
|