fix agent issues
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
cusEntToBillingObjects,
|
||||
type FullCusEntWithFullCusProduct,
|
||||
InternalError,
|
||||
type StripeItemSpec,
|
||||
type UsagePriceConfig,
|
||||
} from "@autumn/shared";
|
||||
@@ -21,10 +22,16 @@ export const allocatedToStripeItemSpec = ({
|
||||
const { price, product } = billing;
|
||||
const config = price.config as UsagePriceConfig;
|
||||
|
||||
if (!config.stripe_price_id) {
|
||||
throw new InternalError({
|
||||
message: `[allocatedToStripeItemSpec] config.stripe_price_id is empty for autumn price: ${price.id}`,
|
||||
});
|
||||
}
|
||||
|
||||
const existingUsage = cusEntToInvoiceUsage({ cusEnt: cusEntWithCusProduct });
|
||||
|
||||
return {
|
||||
stripePriceId: config.stripe_price_id!,
|
||||
stripePriceId: config.stripe_price_id,
|
||||
quantity: existingUsage,
|
||||
autumnPrice: price,
|
||||
autumnProduct: product,
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
import {
|
||||
cusEntsToAllowance,
|
||||
cusEntToPrepaidInvoiceOverage,
|
||||
type FullCusEntWithFullCusProduct,
|
||||
InternalError,
|
||||
isVolumePrice,
|
||||
type Organization,
|
||||
orgToCurrency,
|
||||
priceToLineAmount,
|
||||
type StripeInlinePrice,
|
||||
} from "@autumn/shared";
|
||||
import { cusEntsToPrepaidQuantity } from "@shared/utils/cusEntUtils/balanceUtils/cusEntsToPrepaidQuantity";
|
||||
import { cusEntToCusPrice } from "@shared/utils/cusEntUtils/convertCusEntUtils/cusEntToCusPrice";
|
||||
import { atmnToStripeAmountDecimal } from "@shared/utils/productUtils/priceUtils/convertAmountUtils";
|
||||
import { priceToStripeRecurringParams } from "@shared/utils/productUtils/priceUtils/convertPrice/priceToStripeRecurringParams";
|
||||
import { Decimal } from "decimal.js";
|
||||
|
||||
/**
|
||||
* Builds a flat inline Stripe price for an entity-scoped prepaid item.
|
||||
@@ -44,21 +42,14 @@ export const cusEntToInlineStripePrice = ({
|
||||
});
|
||||
}
|
||||
|
||||
// 1. Get overage (purchased quantity in feature units, excluding allowance)
|
||||
let overage = cusEntsToPrepaidQuantity({
|
||||
cusEnts: [cusEnt],
|
||||
sumAcrossEntities: false,
|
||||
// 1. Get overage (purchased quantity in feature units
|
||||
const overage = cusEntToPrepaidInvoiceOverage({
|
||||
cusEnt,
|
||||
useUpcomingQuantity: true,
|
||||
});
|
||||
|
||||
// 2. Get allowance
|
||||
const allowance = cusEntsToAllowance({ cusEnts: [cusEnt] });
|
||||
|
||||
// 3. Volume pricing: total quantity determines the tier, entire amount is charged
|
||||
if (isVolumePrice(price)) {
|
||||
overage = new Decimal(overage).add(allowance).toNumber();
|
||||
}
|
||||
|
||||
// 4. Calculate total dollar amount using tier logic
|
||||
const totalAmount = priceToLineAmount({
|
||||
price,
|
||||
@@ -74,7 +65,7 @@ export const cusEntToInlineStripePrice = ({
|
||||
return {
|
||||
product: productId,
|
||||
currency,
|
||||
recurring: recurring!,
|
||||
...(recurring && { recurring }),
|
||||
unit_amount_decimal: totalStripeAmount,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
featureOptionUtils,
|
||||
InternalError,
|
||||
isPrepaidPrice,
|
||||
priceUtils,
|
||||
type StripeItemSpec,
|
||||
type UsagePriceConfig,
|
||||
} from "@autumn/shared";
|
||||
@@ -36,8 +37,9 @@ export const prepaidToStripeItemSpec = ({
|
||||
|
||||
const config = price.config as UsagePriceConfig;
|
||||
const isEntityScoped = notNullish(cusProduct.internal_entity_id);
|
||||
const isTieredOneOff = priceUtils.isTieredOneOff({ price, product });
|
||||
|
||||
if (isEntityScoped) {
|
||||
if (isEntityScoped || isTieredOneOff) {
|
||||
const inlinePrice = cusEntToInlineStripePrice({
|
||||
cusEnt: cusEntWithCusProduct,
|
||||
org: ctx.org,
|
||||
@@ -53,6 +55,12 @@ export const prepaidToStripeItemSpec = ({
|
||||
};
|
||||
}
|
||||
|
||||
if (!config.stripe_prepaid_price_v2_id) {
|
||||
throw new InternalError({
|
||||
message: `[prepaidToStripeItemSpec] Price ${price.id} has no stripe_prepaid_price_v2_id`,
|
||||
});
|
||||
}
|
||||
|
||||
const quantity = featureOptionUtils.convert.toV2StripeQuantity({
|
||||
featureOptions: options ?? undefined,
|
||||
price,
|
||||
@@ -60,7 +68,7 @@ export const prepaidToStripeItemSpec = ({
|
||||
});
|
||||
|
||||
return {
|
||||
stripePriceId: config.stripe_prepaid_price_v2_id!,
|
||||
stripePriceId: config.stripe_prepaid_price_v2_id,
|
||||
quantity,
|
||||
autumnPrice: price,
|
||||
autumnEntitlement: entitlement,
|
||||
|
||||
@@ -1,33 +1,68 @@
|
||||
import type { StripeInlinePrice, StripeItemSpec } from "@autumn/shared";
|
||||
import {
|
||||
InternalError,
|
||||
type StripeInlinePrice,
|
||||
type StripeItemSpec,
|
||||
} from "@autumn/shared";
|
||||
import type Stripe from "stripe";
|
||||
|
||||
type StoredPriceParam = { price: string };
|
||||
type RecurringInlinePriceParam = {
|
||||
price_data: Stripe.SubscriptionCreateParams.Item["price_data"];
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the price param for a StripeItemSpec — either a stored price ID or inline price_data.
|
||||
* For inline prices, asserts that `recurring` is present (one-off items should not reach this path).
|
||||
*/
|
||||
const toPriceParam = ({
|
||||
const toRecurringPriceParam = ({
|
||||
spec,
|
||||
}: {
|
||||
spec: StripeItemSpec;
|
||||
}): { price: string } | { price_data: StripeInlinePrice } => {
|
||||
}): StoredPriceParam | RecurringInlinePriceParam => {
|
||||
if (spec.stripeInlinePrice) {
|
||||
return { price_data: spec.stripeInlinePrice };
|
||||
if (!spec.stripeInlinePrice.recurring) {
|
||||
throw new InternalError({
|
||||
message:
|
||||
"stripeItemSpecToSubscriptionItem called with non-recurring inline price — one-off items should use the invoice path",
|
||||
code: "inline_price_missing_recurring",
|
||||
});
|
||||
}
|
||||
return {
|
||||
price_data: {
|
||||
...spec.stripeInlinePrice,
|
||||
recurring: spec.stripeInlinePrice.recurring,
|
||||
},
|
||||
};
|
||||
}
|
||||
return { price: spec.stripePriceId! };
|
||||
};
|
||||
|
||||
/** Converts a StripeItemSpec to a Stripe subscription item param (create or update). */
|
||||
/** Converts a StripeItemSpec to a Stripe subscription item param (create or update).
|
||||
* Only call with recurring specs — one-off items use a separate invoice path. */
|
||||
export const stripeItemSpecToSubscriptionItem = ({
|
||||
spec,
|
||||
}: {
|
||||
spec: StripeItemSpec;
|
||||
}): Stripe.SubscriptionCreateParams.Item => {
|
||||
return {
|
||||
...toPriceParam({ spec }),
|
||||
...toRecurringPriceParam({ spec }),
|
||||
...(spec.quantity !== undefined && { quantity: spec.quantity }),
|
||||
...(spec.metadata && { metadata: spec.metadata }),
|
||||
};
|
||||
};
|
||||
|
||||
/** Returns a price param without recurring validation — for checkout line items. */
|
||||
const toPriceParam = ({
|
||||
spec,
|
||||
}: {
|
||||
spec: StripeItemSpec;
|
||||
}): StoredPriceParam | { price_data: StripeInlinePrice } => {
|
||||
if (spec.stripeInlinePrice) {
|
||||
return { price_data: spec.stripeInlinePrice };
|
||||
}
|
||||
return { price: spec.stripePriceId! };
|
||||
};
|
||||
|
||||
/** Converts a StripeItemSpec to a Stripe checkout session line item. */
|
||||
export const stripeItemSpecToCheckoutLineItem = ({
|
||||
spec,
|
||||
@@ -36,7 +71,7 @@ export const stripeItemSpecToCheckoutLineItem = ({
|
||||
}): Stripe.Checkout.SessionCreateParams.LineItem => {
|
||||
return {
|
||||
...toPriceParam({ spec }),
|
||||
quantity: spec.quantity ?? 0,
|
||||
quantity: spec.quantity,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -47,7 +82,7 @@ export const stripeItemSpecToPhaseItem = ({
|
||||
spec: StripeItemSpec;
|
||||
}): Stripe.SubscriptionScheduleUpdateParams.Phase.Item => {
|
||||
return {
|
||||
...toPriceParam({ spec }),
|
||||
...toRecurringPriceParam({ spec }),
|
||||
...(spec.quantity !== undefined && { quantity: spec.quantity }),
|
||||
...(spec.metadata && { metadata: spec.metadata }),
|
||||
} as Stripe.SubscriptionScheduleUpdateParams.Phase.Item;
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* Attach One-Off Prepaid with Tiered Pricing (Attach V2)
|
||||
*
|
||||
* Tests for attaching one-off products with included usage and tiered pricing
|
||||
* via the direct attach flow (customer already has payment method).
|
||||
*
|
||||
* Test 1: Customer-level attach with tiered one-off
|
||||
* - Included usage: 100 units (1 free pack)
|
||||
* - Tiered pricing: 0-500 @ $10/pack, 501+ @ $5/pack
|
||||
* - Request 800 units → 1 free + 5×$10 + 2×$5 = $60 prepaid
|
||||
* - Total: $10 base + $60 prepaid = $70
|
||||
*
|
||||
* Test 2: Entity-level attach with tiered one-off
|
||||
* - Same tiered pricing, attached to two entities with different quantities
|
||||
* - Entity 1: 300 units → 1 free + 2×$10 = $20 prepaid
|
||||
* - Entity 2: 800 units → 1 free + 5×$10 + 2×$5 = $60 prepaid
|
||||
*/
|
||||
|
||||
import { expect, test } from "bun:test";
|
||||
import type { ApiCustomerV3, ApiEntityV0 } from "@autumn/shared";
|
||||
import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect";
|
||||
import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect";
|
||||
import { expectProductActive } from "@tests/integration/billing/utils/expectCustomerProductCorrect";
|
||||
import { TestFeature } from "@tests/setup/v2Features";
|
||||
import { items } from "@tests/utils/fixtures/items";
|
||||
import { products } from "@tests/utils/fixtures/products";
|
||||
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
|
||||
import chalk from "chalk";
|
||||
|
||||
const BILLING_UNITS = 100;
|
||||
const INCLUDED_USAGE = 100;
|
||||
const BASE_PRICE = 10;
|
||||
const TIERS = [
|
||||
{ to: 500 as const, amount: 10 },
|
||||
{ to: "inf" as const, amount: 5 },
|
||||
];
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// TEST 1: Customer-level one-off with tiered pricing
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test.concurrent(`${chalk.yellowBright("oneoff-prepaid-tiers: customer-level tiered one-off")}`, async () => {
|
||||
const customerId = "oneoff-tiers-customer";
|
||||
const quantity = 800;
|
||||
|
||||
// 800 total = 8 packs: 1 free (includedUsage) + 7 paid
|
||||
// Tier 1 (0-500): 5 paid packs × $10 = $50
|
||||
// Tier 2 (501+): 2 paid packs × $5 = $10
|
||||
// Prepaid total: $60
|
||||
const expectedPrepaidCost = 5 * 10 + 2 * 5;
|
||||
const expectedTotal = BASE_PRICE + expectedPrepaidCost;
|
||||
|
||||
const tieredOneOffItem = items.tieredOneOffMessages({
|
||||
includedUsage: INCLUDED_USAGE,
|
||||
billingUnits: BILLING_UNITS,
|
||||
tiers: TIERS,
|
||||
});
|
||||
|
||||
const oneOff = products.oneOff({
|
||||
id: "one-off-tiered-cus",
|
||||
items: [tieredOneOffItem],
|
||||
});
|
||||
|
||||
const { autumnV1 } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ paymentMethod: "success" }),
|
||||
s.products({ list: [oneOff] }),
|
||||
],
|
||||
actions: [],
|
||||
});
|
||||
|
||||
const preview = await autumnV1.billing.previewAttach({
|
||||
customer_id: customerId,
|
||||
product_id: oneOff.id,
|
||||
options: [{ feature_id: TestFeature.Messages, quantity }],
|
||||
});
|
||||
expect(preview.total).toBe(expectedTotal);
|
||||
|
||||
await autumnV1.billing.attach({
|
||||
customer_id: customerId,
|
||||
product_id: oneOff.id,
|
||||
options: [{ feature_id: TestFeature.Messages, quantity }],
|
||||
redirect_mode: "if_required",
|
||||
});
|
||||
|
||||
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
||||
|
||||
await expectProductActive({ customer, productId: oneOff.id });
|
||||
|
||||
expectCustomerFeatureCorrect({
|
||||
customer,
|
||||
featureId: TestFeature.Messages,
|
||||
balance: quantity,
|
||||
usage: 0,
|
||||
});
|
||||
|
||||
await expectCustomerInvoiceCorrect({
|
||||
customer,
|
||||
count: 1,
|
||||
latestTotal: expectedTotal,
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// TEST 2: Entity-level one-off with tiered pricing
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test.concurrent(`${chalk.yellowBright("oneoff-prepaid-tiers: entity-level tiered one-off")}`, async () => {
|
||||
const customerId = "oneoff-tiers-entity";
|
||||
const quantity1 = 300;
|
||||
const quantity2 = 800;
|
||||
|
||||
// Entity 1: 300 total = 3 packs: 1 free + 2 paid
|
||||
// All 2 paid packs in tier 1 (0-500): 2 × $10 = $20
|
||||
const expectedPrepaidCost1 = 2 * 10;
|
||||
const expectedTotal1 = BASE_PRICE + expectedPrepaidCost1;
|
||||
|
||||
// Entity 2: 800 total = 8 packs: 1 free + 7 paid
|
||||
// Tier 1 (0-500): 5 paid packs × $10 = $50
|
||||
// Tier 2 (501+): 2 paid packs × $5 = $10
|
||||
const expectedPrepaidCost2 = 5 * 10 + 2 * 5;
|
||||
const expectedTotal2 = BASE_PRICE + expectedPrepaidCost2;
|
||||
|
||||
const tieredOneOffItem = items.tieredOneOffMessages({
|
||||
includedUsage: INCLUDED_USAGE,
|
||||
billingUnits: BILLING_UNITS,
|
||||
tiers: TIERS,
|
||||
});
|
||||
|
||||
const oneOff = products.oneOff({
|
||||
id: "one-off-tiered-ent",
|
||||
items: [tieredOneOffItem],
|
||||
});
|
||||
|
||||
const { autumnV1, entities } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ paymentMethod: "success" }),
|
||||
s.products({ list: [oneOff] }),
|
||||
s.entities({ count: 2, featureId: TestFeature.Users }),
|
||||
],
|
||||
actions: [],
|
||||
});
|
||||
|
||||
// Attach to entity 1
|
||||
const preview1 = await autumnV1.billing.previewAttach({
|
||||
customer_id: customerId,
|
||||
product_id: oneOff.id,
|
||||
entity_id: entities[0].id,
|
||||
options: [{ feature_id: TestFeature.Messages, quantity: quantity1 }],
|
||||
});
|
||||
expect(preview1.total).toBe(expectedTotal1);
|
||||
|
||||
await autumnV1.billing.attach({
|
||||
customer_id: customerId,
|
||||
product_id: oneOff.id,
|
||||
entity_id: entities[0].id,
|
||||
options: [{ feature_id: TestFeature.Messages, quantity: quantity1 }],
|
||||
redirect_mode: "if_required",
|
||||
});
|
||||
|
||||
// Attach to entity 2
|
||||
const preview2 = await autumnV1.billing.previewAttach({
|
||||
customer_id: customerId,
|
||||
product_id: oneOff.id,
|
||||
entity_id: entities[1].id,
|
||||
options: [{ feature_id: TestFeature.Messages, quantity: quantity2 }],
|
||||
});
|
||||
expect(preview2.total).toBe(expectedTotal2);
|
||||
|
||||
await autumnV1.billing.attach({
|
||||
customer_id: customerId,
|
||||
product_id: oneOff.id,
|
||||
entity_id: entities[1].id,
|
||||
options: [{ feature_id: TestFeature.Messages, quantity: quantity2 }],
|
||||
redirect_mode: "if_required",
|
||||
});
|
||||
|
||||
// Verify entity 1 balance
|
||||
const entity1 = await autumnV1.entities.get<ApiEntityV0>(
|
||||
customerId,
|
||||
entities[0].id,
|
||||
);
|
||||
await expectProductActive({ customer: entity1, productId: oneOff.id });
|
||||
expectCustomerFeatureCorrect({
|
||||
customer: entity1,
|
||||
featureId: TestFeature.Messages,
|
||||
balance: quantity1,
|
||||
usage: 0,
|
||||
});
|
||||
|
||||
// Verify entity 2 balance
|
||||
const entity2 = await autumnV1.entities.get<ApiEntityV0>(
|
||||
customerId,
|
||||
entities[1].id,
|
||||
);
|
||||
await expectProductActive({ customer: entity2, productId: oneOff.id });
|
||||
expectCustomerFeatureCorrect({
|
||||
customer: entity2,
|
||||
featureId: TestFeature.Messages,
|
||||
balance: quantity2,
|
||||
usage: 0,
|
||||
});
|
||||
|
||||
// Verify invoices: 2 total (one per entity attach)
|
||||
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
||||
await expectCustomerInvoiceCorrect({
|
||||
customer,
|
||||
count: 2,
|
||||
latestTotal: expectedTotal2,
|
||||
});
|
||||
await expectCustomerInvoiceCorrect({
|
||||
customer,
|
||||
count: 2,
|
||||
invoiceIndex: 1,
|
||||
latestTotal: expectedTotal1,
|
||||
});
|
||||
});
|
||||
@@ -184,12 +184,12 @@ test.concurrent(`${chalk.yellowBright("attach-prepaid-volume-entities: volume wi
|
||||
const quantity1 = 800;
|
||||
const quantity2 = 400;
|
||||
|
||||
// Entity 1: 600 purchased → 6 packs → tier 2 → 6×$5 = $30
|
||||
const purchasedPacks1 = (quantity1 - includedUsage) / BILLING_UNITS;
|
||||
// Entity 1: 800 (including included usage) purchased
|
||||
const purchasedPacks1 = quantity1 / BILLING_UNITS;
|
||||
const volExpected1 = purchasedPacks1 * 5; // tier 2 rate (>5 packs)
|
||||
|
||||
// Entity 2: 200 purchased → 2 packs → tier 1 → 2×$10 = $20
|
||||
const purchasedPacks2 = (quantity2 - includedUsage) / BILLING_UNITS;
|
||||
// Entity 2: 400 (including included usage) purchased
|
||||
const purchasedPacks2 = quantity2 / BILLING_UNITS;
|
||||
const volExpected2 = purchasedPacks2 * 10; // tier 1 rate (≤5 packs)
|
||||
|
||||
const volItem = items.volumePrepaidMessages({
|
||||
|
||||
@@ -21,7 +21,11 @@ export const validateSchedulePhases = async ({
|
||||
scheduledPhases: Stripe.SubscriptionScheduleUpdateParams.Phase[];
|
||||
debug?: boolean;
|
||||
}) => {
|
||||
if (!sub.schedule) return;
|
||||
expect(
|
||||
sub.schedule,
|
||||
`Expected subscription ${sub.id} to have a schedule for phase validation`,
|
||||
).not.toBeNull();
|
||||
if (!sub.schedule) return; // type narrowing only — expect above will fail first
|
||||
|
||||
const scheduleId =
|
||||
typeof sub.schedule === "string" ? sub.schedule : sub.schedule.id;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { expect } from "bun:test";
|
||||
import type { TestContext } from "@tests/utils/testInitUtils/createTestContext";
|
||||
import type Stripe from "stripe";
|
||||
import { isStripeSubscriptionCanceling } from "@/external/stripe/subscriptions/utils/classifyStripeSubscriptionUtils";
|
||||
import type { PhaseScenario } from "../classifyPhaseScenario";
|
||||
|
||||
/**
|
||||
@@ -52,30 +53,30 @@ export const validateSubState = async ({
|
||||
sub,
|
||||
scenario,
|
||||
cancelAtSeconds,
|
||||
shouldBeCanceled,
|
||||
shouldBeCanceling,
|
||||
debug,
|
||||
}: {
|
||||
ctx: TestContext;
|
||||
sub: Stripe.Subscription;
|
||||
scenario: PhaseScenario;
|
||||
cancelAtSeconds?: number;
|
||||
shouldBeCanceled?: boolean;
|
||||
shouldBeCanceling?: boolean;
|
||||
debug?: boolean;
|
||||
}) => {
|
||||
// Explicit override takes priority
|
||||
if (shouldBeCanceled === true) {
|
||||
if (shouldBeCanceling === true) {
|
||||
expect(
|
||||
sub.cancel_at !== null ||
|
||||
sub.canceled_at !== null ||
|
||||
sub.cancel_at_period_end,
|
||||
isStripeSubscriptionCanceling(sub),
|
||||
"Expected subscription to be canceling",
|
||||
).toBe(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (shouldBeCanceled === false) {
|
||||
expect(sub.cancel_at).toBeNull();
|
||||
expect(sub.canceled_at).toBeNull();
|
||||
if (shouldBeCanceling === false) {
|
||||
expect(
|
||||
isStripeSubscriptionCanceling(sub),
|
||||
"Expected subscription to NOT be canceling",
|
||||
).toBe(false);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { BillingVersion } from "@autumn/shared";
|
||||
|
||||
export type ExpectStripeSubOptions = {
|
||||
status?: "active" | "trialing";
|
||||
shouldBeCanceled?: boolean;
|
||||
shouldBeCanceling?: boolean;
|
||||
subId?: string;
|
||||
subCount?: number;
|
||||
rewards?: string[];
|
||||
|
||||
@@ -102,7 +102,7 @@ export const verifySubscription = async ({
|
||||
sub,
|
||||
scenario,
|
||||
cancelAtSeconds,
|
||||
shouldBeCanceled: options?.shouldBeCanceled,
|
||||
shouldBeCanceling: options?.shouldBeCanceling,
|
||||
debug,
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user