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
265 lines
8.4 KiB
TypeScript
265 lines
8.4 KiB
TypeScript
import { createClient, type RedisClientType } from 'redis';
|
|
import { isDefined, isNonEmptyArray } from 'twenty-shared/utils';
|
|
|
|
import { type BillingCreditGrantType } from 'src/engine/core-modules/billing/enums/billing-credit-grant-type.enum';
|
|
import { type SubscriptionStatus } from 'src/engine/core-modules/billing/enums/billing-subscription-status.enum';
|
|
import { type BillingUsageCacheService } from 'src/engine/core-modules/billing/services/billing-usage-cache.service';
|
|
import { CacheStorageNamespace } from 'src/engine/core-modules/cache-storage/types/cache-storage-namespace.enum';
|
|
import { buildAllowanceCounterKey } from 'src/engine/core-modules/usage-limit/utils/build-allowance-counter-key.util';
|
|
|
|
import { getAppProviderByClassName } from 'test/integration/utils/get-app-provider-by-class-name.util';
|
|
|
|
// The dev seeder gives every workspace a billingCustomer and an active
|
|
// billingSubscription, but no subscription item, and no period. Rollover needs
|
|
// the whole chain — subscription -> item -> product -> price with a
|
|
// credit_amount — so these helpers finish the fixture and put the period where
|
|
// the test wants it.
|
|
export const TEST_STRIPE_CUSTOMER_ID = 'cus_default0';
|
|
export const TEST_STRIPE_SUBSCRIPTION_ID = 'sub_default0';
|
|
|
|
const TEST_STRIPE_PRODUCT_ID = 'prod_resource_credit_test';
|
|
const TEST_STRIPE_PRICE_ID = 'price_resource_credit_test';
|
|
const TEST_STRIPE_SUBSCRIPTION_ITEM_ID = 'si_resource_credit_test';
|
|
|
|
export type CreditGrantRow = {
|
|
id: string;
|
|
amountMicro: number;
|
|
type: BillingCreditGrantType;
|
|
effectiveAt: Date;
|
|
expiresAt: Date | null;
|
|
revokedAt: Date | null;
|
|
reason: string | null;
|
|
idempotencyKey: string | null;
|
|
sourceGrantId: string | null;
|
|
};
|
|
|
|
const query = async <T>(sql: string, params: unknown[] = []): Promise<T[]> =>
|
|
global.testDataSource.query(sql, params);
|
|
|
|
// The billing seeds insert with orIgnore, so the customer and subscription
|
|
// land on exactly one workspace and which one is not worth hardcoding.
|
|
export const getSeededBillingWorkspaceId = async (): Promise<string> => {
|
|
const [row] = await query<{ workspaceId: string }>(
|
|
`SELECT "workspaceId" FROM core."billingSubscription" WHERE "stripeSubscriptionId" = $1`,
|
|
[TEST_STRIPE_SUBSCRIPTION_ID],
|
|
);
|
|
|
|
if (!row) {
|
|
throw new Error(
|
|
`No seeded billing subscription ${TEST_STRIPE_SUBSCRIPTION_ID}: the dev seeder did not run`,
|
|
);
|
|
}
|
|
|
|
return row.workspaceId;
|
|
};
|
|
|
|
export const setupResourceCreditSubscription = async ({
|
|
workspaceId,
|
|
periodStart,
|
|
periodEnd,
|
|
creditAmountMicro,
|
|
status = 'active',
|
|
trialStart = null,
|
|
trialEnd = null,
|
|
}: {
|
|
workspaceId: string;
|
|
periodStart: Date;
|
|
periodEnd: Date;
|
|
creditAmountMicro: number;
|
|
status?: string;
|
|
trialStart?: Date | null;
|
|
trialEnd?: Date | null;
|
|
}): Promise<{ subscriptionId: string }> => {
|
|
await query(
|
|
`UPDATE core."billingSubscription"
|
|
SET "currentPeriodStart" = $2, "currentPeriodEnd" = $3, status = $4,
|
|
"trialStart" = $5, "trialEnd" = $6, interval = 'month'
|
|
WHERE "workspaceId" = $1 AND "stripeSubscriptionId" = $7`,
|
|
[
|
|
workspaceId,
|
|
periodStart,
|
|
periodEnd,
|
|
status,
|
|
trialStart,
|
|
trialEnd,
|
|
TEST_STRIPE_SUBSCRIPTION_ID,
|
|
],
|
|
);
|
|
|
|
const [subscription] = await query<{ id: string }>(
|
|
`SELECT id FROM core."billingSubscription"
|
|
WHERE "workspaceId" = $1 AND "stripeSubscriptionId" = $2`,
|
|
[workspaceId, TEST_STRIPE_SUBSCRIPTION_ID],
|
|
);
|
|
|
|
await query(
|
|
`INSERT INTO core."billingProduct"
|
|
("stripeProductId", active, name, description, metadata)
|
|
VALUES ($1, true, 'Test resource credit', '', $2)
|
|
ON CONFLICT ("stripeProductId") DO UPDATE SET metadata = EXCLUDED.metadata`,
|
|
[TEST_STRIPE_PRODUCT_ID, JSON.stringify({ productKey: 'RESOURCE_CREDIT' })],
|
|
);
|
|
|
|
await query(
|
|
`INSERT INTO core."billingPrice"
|
|
("stripePriceId", "stripeProductId", active, currency, "taxBehavior",
|
|
type, "billingScheme", "usageType", interval, "unitAmount", metadata)
|
|
VALUES ($1, $2, true, 'usd', 'UNSPECIFIED', 'RECURRING', 'PER_UNIT',
|
|
'LICENSED', 'month', 1000, $3)
|
|
ON CONFLICT ("stripePriceId") DO UPDATE SET metadata = EXCLUDED.metadata`,
|
|
[
|
|
TEST_STRIPE_PRICE_ID,
|
|
TEST_STRIPE_PRODUCT_ID,
|
|
JSON.stringify({ credit_amount: String(creditAmountMicro) }),
|
|
],
|
|
);
|
|
|
|
await query(
|
|
`INSERT INTO core."billingSubscriptionItem"
|
|
("billingSubscriptionId", "stripeSubscriptionId", "stripeProductId",
|
|
"stripePriceId", "stripeSubscriptionItemId", quantity)
|
|
VALUES ($1, $2, $3, $4, $5, 1)
|
|
ON CONFLICT ("stripeSubscriptionItemId") DO UPDATE
|
|
SET "stripePriceId" = EXCLUDED."stripePriceId",
|
|
"stripeProductId" = EXCLUDED."stripeProductId"`,
|
|
[
|
|
subscription.id,
|
|
TEST_STRIPE_SUBSCRIPTION_ID,
|
|
TEST_STRIPE_PRODUCT_ID,
|
|
TEST_STRIPE_PRICE_ID,
|
|
TEST_STRIPE_SUBSCRIPTION_ITEM_ID,
|
|
],
|
|
);
|
|
|
|
return { subscriptionId: subscription.id };
|
|
};
|
|
|
|
export const insertCreditGrant = async ({
|
|
workspaceId,
|
|
amountMicro,
|
|
type,
|
|
effectiveAt,
|
|
expiresAt,
|
|
idempotencyKey = null,
|
|
}: {
|
|
workspaceId: string;
|
|
amountMicro: number;
|
|
type: BillingCreditGrantType;
|
|
effectiveAt: Date;
|
|
expiresAt: Date | null;
|
|
idempotencyKey?: string | null;
|
|
}): Promise<string> => {
|
|
const [row] = await query<{ id: string }>(
|
|
`INSERT INTO core."billingCreditGrant"
|
|
("workspaceId", "amountMicro", type, "effectiveAt", "expiresAt", "idempotencyKey")
|
|
VALUES ($1, $2, $3, $4, $5, $6)
|
|
RETURNING id`,
|
|
[workspaceId, amountMicro, type, effectiveAt, expiresAt, idempotencyKey],
|
|
);
|
|
|
|
return row.id;
|
|
};
|
|
|
|
export const listCreditGrants = async (
|
|
workspaceId: string,
|
|
): Promise<CreditGrantRow[]> =>
|
|
query<CreditGrantRow>(
|
|
`SELECT id, "amountMicro"::bigint::int AS "amountMicro", type, "effectiveAt",
|
|
"expiresAt", "revokedAt", reason, "idempotencyKey", "sourceGrantId"
|
|
FROM core."billingCreditGrant"
|
|
WHERE "workspaceId" = $1
|
|
ORDER BY "createdAt" ASC`,
|
|
[workspaceId],
|
|
);
|
|
|
|
let redisClient: RedisClientType | null = null;
|
|
|
|
const getRedisClient = async (): Promise<RedisClientType> => {
|
|
if (!isDefined(redisClient)) {
|
|
redisClient = createClient({ url: process.env.REDIS_URL });
|
|
await redisClient.connect();
|
|
}
|
|
|
|
return redisClient;
|
|
};
|
|
|
|
export const quitBillingFixtureRedis = async (): Promise<void> => {
|
|
if (isDefined(redisClient)) {
|
|
await redisClient.quit();
|
|
redisClient = null;
|
|
}
|
|
};
|
|
|
|
const buildTestAllowanceCounterKey = (
|
|
workspaceId: string,
|
|
periodStart: Date,
|
|
): string =>
|
|
`${CacheStorageNamespace.IntegrationTests}:${CacheStorageNamespace.EngineUsageLimit}:${buildAllowanceCounterKey({ workspaceId, periodStart })}`;
|
|
|
|
export const warmAllowanceCounter = async (
|
|
workspaceId: string,
|
|
periodStart: Date,
|
|
valueMicro: number,
|
|
): Promise<void> => {
|
|
const redis = await getRedisClient();
|
|
|
|
await redis.set(
|
|
buildTestAllowanceCounterKey(workspaceId, periodStart),
|
|
String(valueMicro),
|
|
);
|
|
};
|
|
|
|
export const readAllowanceCounter = async (
|
|
workspaceId: string,
|
|
periodStart: Date,
|
|
): Promise<number | null> => {
|
|
const redis = await getRedisClient();
|
|
const value = await redis.get(
|
|
buildTestAllowanceCounterKey(workspaceId, periodStart),
|
|
);
|
|
|
|
return isDefined(value) ? Number(value) : null;
|
|
};
|
|
|
|
export const resetBillingCreditState = async (
|
|
workspaceId: string,
|
|
): Promise<void> => {
|
|
await query(
|
|
`DELETE FROM core."billingCreditGrant" WHERE "workspaceId" = $1`,
|
|
[workspaceId],
|
|
);
|
|
|
|
const cache = getBillingUsageCacheService();
|
|
|
|
await cache.flushAvailableCredits(workspaceId);
|
|
await cache.flushCounterAdjustmentMarkers(workspaceId);
|
|
|
|
const redis = await getRedisClient();
|
|
const staleKeys = [
|
|
...(await redis.keys(`*{${workspaceId}}:quota:allowance:*`)),
|
|
...(await redis.keys(`*currentBillingSubscription:${workspaceId}*`)),
|
|
];
|
|
|
|
if (isNonEmptyArray(staleKeys)) {
|
|
await redis.del(staleKeys);
|
|
}
|
|
};
|
|
|
|
export const getBillingUsageCacheService = (): BillingUsageCacheService =>
|
|
getAppProviderByClassName<BillingUsageCacheService>(
|
|
'BillingUsageCacheService',
|
|
);
|
|
|
|
// Cancelling is what makes getCurrentBillingSubscription stop returning it, so
|
|
// this is how a test reaches the no-subscription path without deleting rows the
|
|
// rest of the suite shares.
|
|
export const setSubscriptionStatus = async (
|
|
workspaceId: string,
|
|
status: SubscriptionStatus,
|
|
): Promise<void> => {
|
|
await query(
|
|
`UPDATE "core"."billingSubscription" SET status = $2 WHERE "workspaceId" = $1`,
|
|
[workspaceId, status],
|
|
);
|
|
};
|