Add shipping cost groups and reject spam contact-form SMS.
Wire cart/checkout shipping fees with category groups and static rates, reject gibberish contact submissions, and SMS only valid Iranian mobiles. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
1a49576752
commit
88ef4e82a1
@@ -0,0 +1,58 @@
|
|||||||
|
-- Shipping cost groups (هزینه ارسال) + per-city overrides + category assignment
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS shipping_cost_groups (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
business_id BIGINT NOT NULL REFERENCES businesses (id) ON DELETE CASCADE ON UPDATE NO ACTION,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
name_en VARCHAR(255) NOT NULL,
|
||||||
|
description TEXT NULL,
|
||||||
|
methods TEXT[] NOT NULL DEFAULT '{}',
|
||||||
|
default_amount NUMERIC(12, 2) NOT NULL DEFAULT 0,
|
||||||
|
sort_order INT NOT NULL DEFAULT 0,
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_shipping_cost_groups_business_id
|
||||||
|
ON shipping_cost_groups (business_id);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_shipping_cost_groups_business_sort_order
|
||||||
|
ON shipping_cost_groups (business_id, sort_order);
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS shipping_cost_groups_set_updated_at ON shipping_cost_groups;
|
||||||
|
CREATE TRIGGER shipping_cost_groups_set_updated_at
|
||||||
|
BEFORE UPDATE ON shipping_cost_groups
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION set_updated_at();
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS shipping_cost_group_city_rates (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
shipping_cost_group_id BIGINT NOT NULL REFERENCES shipping_cost_groups (id) ON DELETE CASCADE ON UPDATE NO ACTION,
|
||||||
|
city_id BIGINT NOT NULL REFERENCES cities (id) ON DELETE CASCADE ON UPDATE NO ACTION,
|
||||||
|
amount NUMERIC(12, 2) NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
CONSTRAINT shipping_cost_group_city_rates_unique UNIQUE (shipping_cost_group_id, city_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_shipping_cost_group_city_rates_group_id
|
||||||
|
ON shipping_cost_group_city_rates (shipping_cost_group_id);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_shipping_cost_group_city_rates_city_id
|
||||||
|
ON shipping_cost_group_city_rates (city_id);
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS shipping_cost_group_city_rates_set_updated_at ON shipping_cost_group_city_rates;
|
||||||
|
CREATE TRIGGER shipping_cost_group_city_rates_set_updated_at
|
||||||
|
BEFORE UPDATE ON shipping_cost_group_city_rates
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION set_updated_at();
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS shipping_cost_group_categories (
|
||||||
|
shipping_cost_group_id BIGINT NOT NULL REFERENCES shipping_cost_groups (id) ON DELETE CASCADE ON UPDATE NO ACTION,
|
||||||
|
category_id BIGINT NOT NULL REFERENCES categories (id) ON DELETE CASCADE ON UPDATE NO ACTION,
|
||||||
|
PRIMARY KEY (shipping_cost_group_id, category_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_shipping_cost_group_categories_category_id
|
||||||
|
ON shipping_cost_group_categories (category_id);
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
-- Default shipping cost group per business (fallback for unassigned categories)
|
||||||
|
|
||||||
|
ALTER TABLE shipping_cost_groups
|
||||||
|
ADD COLUMN IF NOT EXISTS is_default BOOLEAN NOT NULL DEFAULT FALSE;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_shipping_cost_groups_business_default
|
||||||
|
ON shipping_cost_groups (business_id)
|
||||||
|
WHERE is_default = TRUE;
|
||||||
@@ -161,6 +161,7 @@ model Business {
|
|||||||
storeItemVariants StoreItemVariant[]
|
storeItemVariants StoreItemVariant[]
|
||||||
storeItems StoreItem[]
|
storeItems StoreItem[]
|
||||||
storeLoanMethods StoreLoanMethod[]
|
storeLoanMethods StoreLoanMethod[]
|
||||||
|
shippingCostGroups ShippingCostGroup[]
|
||||||
storeSpecials StoreSpecial[]
|
storeSpecials StoreSpecial[]
|
||||||
transactions Transaction[]
|
transactions Transaction[]
|
||||||
userProductTechnicalFieldValues UserProductTechnicalFieldValue[]
|
userProductTechnicalFieldValues UserProductTechnicalFieldValue[]
|
||||||
@@ -443,6 +444,7 @@ model Category {
|
|||||||
assignments CategoryAssignment[]
|
assignments CategoryAssignment[]
|
||||||
technicalForm CategoryTechnicalForm?
|
technicalForm CategoryTechnicalForm?
|
||||||
variations CategoryVariation[]
|
variations CategoryVariation[]
|
||||||
|
shippingCostGroupCategories ShippingCostGroupCategory[]
|
||||||
website_category_group_items website_category_group_items[]
|
website_category_group_items website_category_group_items[]
|
||||||
|
|
||||||
@@unique([businessId, entityType, slug], map: "categories_business_entity_slug_unique")
|
@@unique([businessId, entityType, slug], map: "categories_business_entity_slug_unique")
|
||||||
@@ -943,6 +945,7 @@ model City {
|
|||||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||||
parent City? @relation("CityTree", fields: [parentId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
parent City? @relation("CityTree", fields: [parentId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||||
children City[] @relation("CityTree")
|
children City[] @relation("CityTree")
|
||||||
|
shippingCostCityRates ShippingCostGroupCityRate[]
|
||||||
userProductsAsCity UserProduct[] @relation("UserProductCity")
|
userProductsAsCity UserProduct[] @relation("UserProductCity")
|
||||||
userProductsAsCountry UserProduct[] @relation("UserProductCountry")
|
userProductsAsCountry UserProduct[] @relation("UserProductCountry")
|
||||||
userProductsAsDistrict UserProduct[] @relation("UserProductDistrict")
|
userProductsAsDistrict UserProduct[] @relation("UserProductDistrict")
|
||||||
@@ -1254,6 +1257,55 @@ model StoreLoanMethod {
|
|||||||
@@map("store_loan_methods")
|
@@map("store_loan_methods")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model ShippingCostGroup {
|
||||||
|
id BigInt @id @default(autoincrement())
|
||||||
|
businessId BigInt @map("business_id")
|
||||||
|
nameFa String @map("name") @db.VarChar(255)
|
||||||
|
nameEn String @map("name_en") @db.VarChar(255)
|
||||||
|
description String?
|
||||||
|
methods String[] @default([])
|
||||||
|
defaultAmount Decimal @default(0) @map("default_amount") @db.Decimal(12, 2)
|
||||||
|
sortOrder Int @default(0) @map("sort_order")
|
||||||
|
isActive Boolean @default(true) @map("is_active")
|
||||||
|
isDefault Boolean @default(false) @map("is_default")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||||
|
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||||
|
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||||
|
cityRates ShippingCostGroupCityRate[]
|
||||||
|
categories ShippingCostGroupCategory[]
|
||||||
|
|
||||||
|
@@index([businessId], map: "idx_shipping_cost_groups_business_id")
|
||||||
|
@@index([businessId, sortOrder], map: "idx_shipping_cost_groups_business_sort_order")
|
||||||
|
@@map("shipping_cost_groups")
|
||||||
|
}
|
||||||
|
|
||||||
|
model ShippingCostGroupCityRate {
|
||||||
|
id BigInt @id @default(autoincrement())
|
||||||
|
shippingCostGroupId BigInt @map("shipping_cost_group_id")
|
||||||
|
cityId BigInt @map("city_id")
|
||||||
|
amount Decimal @db.Decimal(12, 2)
|
||||||
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||||
|
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||||
|
shippingCostGroup ShippingCostGroup @relation(fields: [shippingCostGroupId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||||
|
city City @relation(fields: [cityId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||||
|
|
||||||
|
@@unique([shippingCostGroupId, cityId], map: "shipping_cost_group_city_rates_unique")
|
||||||
|
@@index([shippingCostGroupId], map: "idx_shipping_cost_group_city_rates_group_id")
|
||||||
|
@@index([cityId], map: "idx_shipping_cost_group_city_rates_city_id")
|
||||||
|
@@map("shipping_cost_group_city_rates")
|
||||||
|
}
|
||||||
|
|
||||||
|
model ShippingCostGroupCategory {
|
||||||
|
shippingCostGroupId BigInt @map("shipping_cost_group_id")
|
||||||
|
categoryId BigInt @map("category_id")
|
||||||
|
shippingCostGroup ShippingCostGroup @relation(fields: [shippingCostGroupId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||||
|
category Category @relation(fields: [categoryId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||||
|
|
||||||
|
@@id([shippingCostGroupId, categoryId])
|
||||||
|
@@index([categoryId], map: "idx_shipping_cost_group_categories_category_id")
|
||||||
|
@@map("shipping_cost_group_categories")
|
||||||
|
}
|
||||||
|
|
||||||
model website_brand_group_items {
|
model website_brand_group_items {
|
||||||
id BigInt @id @default(autoincrement())
|
id BigInt @id @default(autoincrement())
|
||||||
group_id BigInt
|
group_id BigInt
|
||||||
|
|||||||
@@ -501,6 +501,18 @@ export function toGamaMsisdn(raw: string): string | null {
|
|||||||
return msisdn;
|
return msisdn;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Iranian mobile only (local 09xxxxxxxxx → `989xxxxxxxxx`).
|
||||||
|
* Rejects landlines, foreign numbers, and invalid formats.
|
||||||
|
*/
|
||||||
|
export function toIranianMobileMsisdn(raw: string): string | null {
|
||||||
|
const msisdn = toGamaMsisdn(raw);
|
||||||
|
if (!msisdn || !/^989\d{9}$/.test(msisdn)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return msisdn;
|
||||||
|
}
|
||||||
|
|
||||||
export function maskMsisdn(msisdn: string): string {
|
export function maskMsisdn(msisdn: string): string {
|
||||||
if (msisdn.length < 6) return '***';
|
if (msisdn.length < 6) return '***';
|
||||||
return `${msisdn.slice(0, 4)}****${msisdn.slice(-3)}`;
|
return `${msisdn.slice(0, 4)}****${msisdn.slice(-3)}`;
|
||||||
|
|||||||
@@ -170,6 +170,21 @@ export class BusinessSettingsService {
|
|||||||
}).store.orderProcessSteps
|
}).store.orderProcessSteps
|
||||||
: current.store.orderProcessSteps,
|
: current.store.orderProcessSteps,
|
||||||
ePayment: ePaymentPatch ?? current.store.ePayment,
|
ePayment: ePaymentPatch ?? current.store.ePayment,
|
||||||
|
staticShippingEnabled:
|
||||||
|
dto.store.staticShippingEnabled ??
|
||||||
|
current.store.staticShippingEnabled,
|
||||||
|
staticShippingAmount:
|
||||||
|
dto.store.staticShippingAmount ??
|
||||||
|
current.store.staticShippingAmount,
|
||||||
|
staticShippingProvinceExceptions:
|
||||||
|
dto.store.staticShippingProvinceExceptions !== undefined
|
||||||
|
? normalizeBusinessSettings({
|
||||||
|
store: {
|
||||||
|
staticShippingProvinceExceptions:
|
||||||
|
dto.store.staticShippingProvinceExceptions,
|
||||||
|
},
|
||||||
|
}).store.staticShippingProvinceExceptions
|
||||||
|
: current.store.staticShippingProvinceExceptions,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -92,6 +92,12 @@ export type EPaymentSettings = {
|
|||||||
gateways: PaymentGatewaysSettings;
|
gateways: PaymentGatewaysSettings;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Per-province override for the static flat shipping fee. */
|
||||||
|
export type StaticShippingProvinceException = {
|
||||||
|
provinceId: string;
|
||||||
|
amount: number;
|
||||||
|
};
|
||||||
|
|
||||||
/** Per-business store / sales settings. */
|
/** Per-business store / sales settings. */
|
||||||
export type StoreSettings = {
|
export type StoreSettings = {
|
||||||
onlineSellEnabled: boolean;
|
onlineSellEnabled: boolean;
|
||||||
@@ -99,6 +105,14 @@ export type StoreSettings = {
|
|||||||
torobEnabled: boolean;
|
torobEnabled: boolean;
|
||||||
orderProcessSteps: OrderProcessStep[];
|
orderProcessSteps: OrderProcessStep[];
|
||||||
ePayment: EPaymentSettings;
|
ePayment: EPaymentSettings;
|
||||||
|
/**
|
||||||
|
* When true, every order uses `staticShippingAmount` and shipping cost groups are ignored.
|
||||||
|
*/
|
||||||
|
staticShippingEnabled: boolean;
|
||||||
|
/** Flat shipping fee in IRT applied to all orders when static shipping is enabled. */
|
||||||
|
staticShippingAmount: number;
|
||||||
|
/** Province-specific overrides of the static flat fee. */
|
||||||
|
staticShippingProvinceExceptions: StaticShippingProvinceException[];
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Where website special-product carousels read from. */
|
/** Where website special-product carousels read from. */
|
||||||
@@ -356,6 +370,9 @@ export const DEFAULT_BUSINESS_SETTINGS: BusinessSettings = {
|
|||||||
torobEnabled: false,
|
torobEnabled: false,
|
||||||
orderProcessSteps: DEFAULT_ORDER_PROCESS_STEPS,
|
orderProcessSteps: DEFAULT_ORDER_PROCESS_STEPS,
|
||||||
ePayment: { ...DEFAULT_EPAYMENT_SETTINGS },
|
ePayment: { ...DEFAULT_EPAYMENT_SETTINGS },
|
||||||
|
staticShippingEnabled: true,
|
||||||
|
staticShippingAmount: 0,
|
||||||
|
staticShippingProvinceExceptions: [],
|
||||||
},
|
},
|
||||||
modules: {
|
modules: {
|
||||||
enabled: DEFAULT_ENABLED_BUSINESS_MODULES,
|
enabled: DEFAULT_ENABLED_BUSINESS_MODULES,
|
||||||
|
|||||||
@@ -119,6 +119,44 @@ function readBoolean(value: unknown, fallback: boolean) {
|
|||||||
return typeof value === 'boolean' ? value : fallback;
|
return typeof value === 'boolean' ? value : fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readNonNegativeNumber(value: unknown, fallback: number) {
|
||||||
|
if (typeof value === 'number' && Number.isFinite(value) && value >= 0) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (typeof value === 'string' && value.trim() !== '') {
|
||||||
|
const parsed = Number(value);
|
||||||
|
if (Number.isFinite(parsed) && parsed >= 0) return parsed;
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readStaticShippingProvinceExceptions(
|
||||||
|
value: unknown,
|
||||||
|
): BusinessSettings['store']['staticShippingProvinceExceptions'] {
|
||||||
|
if (!Array.isArray(value)) return [];
|
||||||
|
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const items: BusinessSettings['store']['staticShippingProvinceExceptions'] =
|
||||||
|
[];
|
||||||
|
|
||||||
|
for (const entry of value) {
|
||||||
|
if (!isRecord(entry)) continue;
|
||||||
|
const provinceId =
|
||||||
|
typeof entry.provinceId === 'string'
|
||||||
|
? entry.provinceId.trim()
|
||||||
|
: typeof entry.provinceId === 'number'
|
||||||
|
? String(entry.provinceId)
|
||||||
|
: '';
|
||||||
|
if (!provinceId || seen.has(provinceId)) continue;
|
||||||
|
const amount = readNonNegativeNumber(entry.amount, Number.NaN);
|
||||||
|
if (!Number.isFinite(amount)) continue;
|
||||||
|
seen.add(provinceId);
|
||||||
|
items.push({ provinceId, amount });
|
||||||
|
}
|
||||||
|
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
function readString(value: unknown, fallback = '') {
|
function readString(value: unknown, fallback = '') {
|
||||||
return typeof value === 'string' ? value.trim() : fallback;
|
return typeof value === 'string' ? value.trim() : fallback;
|
||||||
}
|
}
|
||||||
@@ -431,6 +469,17 @@ export function normalizeBusinessSettings(raw: unknown): BusinessSettings {
|
|||||||
),
|
),
|
||||||
orderProcessSteps: readOrderProcessSteps(store.orderProcessSteps),
|
orderProcessSteps: readOrderProcessSteps(store.orderProcessSteps),
|
||||||
ePayment: normalizeEPaymentSettings(store.ePayment),
|
ePayment: normalizeEPaymentSettings(store.ePayment),
|
||||||
|
staticShippingEnabled: readBoolean(
|
||||||
|
store.staticShippingEnabled,
|
||||||
|
DEFAULT_BUSINESS_SETTINGS.store.staticShippingEnabled,
|
||||||
|
),
|
||||||
|
staticShippingAmount: readNonNegativeNumber(
|
||||||
|
store.staticShippingAmount,
|
||||||
|
DEFAULT_BUSINESS_SETTINGS.store.staticShippingAmount,
|
||||||
|
),
|
||||||
|
staticShippingProvinceExceptions: readStaticShippingProvinceExceptions(
|
||||||
|
store.staticShippingProvinceExceptions,
|
||||||
|
),
|
||||||
},
|
},
|
||||||
modules: {
|
modules: {
|
||||||
// Missing `modules` → all enabled (legacy). Explicit `{ enabled: [] }` stays empty.
|
// Missing `modules` → all enabled (legacy). Explicit `{ enabled: [] }` stays empty.
|
||||||
@@ -487,6 +536,14 @@ export function mergeBusinessSettings(
|
|||||||
current.store.ePayment,
|
current.store.ePayment,
|
||||||
patch.store?.ePayment,
|
patch.store?.ePayment,
|
||||||
),
|
),
|
||||||
|
staticShippingEnabled:
|
||||||
|
patch.store?.staticShippingEnabled ??
|
||||||
|
current.store.staticShippingEnabled,
|
||||||
|
staticShippingAmount:
|
||||||
|
patch.store?.staticShippingAmount ?? current.store.staticShippingAmount,
|
||||||
|
staticShippingProvinceExceptions:
|
||||||
|
patch.store?.staticShippingProvinceExceptions ??
|
||||||
|
current.store.staticShippingProvinceExceptions,
|
||||||
},
|
},
|
||||||
modules: {
|
modules: {
|
||||||
enabled: patch.modules?.enabled ?? current.modules.enabled,
|
enabled: patch.modules?.enabled ?? current.modules.enabled,
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ import {
|
|||||||
IsArray,
|
IsArray,
|
||||||
IsBoolean,
|
IsBoolean,
|
||||||
IsIn,
|
IsIn,
|
||||||
|
IsNumber,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsString,
|
IsString,
|
||||||
|
Min,
|
||||||
MinLength,
|
MinLength,
|
||||||
ValidateIf,
|
ValidateIf,
|
||||||
ValidateNested,
|
ValidateNested,
|
||||||
@@ -163,6 +165,17 @@ class EPaymentSettingsDto {
|
|||||||
gateways?: PaymentGatewaysSettingsDto;
|
gateways?: PaymentGatewaysSettingsDto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class StaticShippingProvinceExceptionDto {
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
provinceId!: string;
|
||||||
|
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsNumber({ maxDecimalPlaces: 2 })
|
||||||
|
@Min(0)
|
||||||
|
amount!: number;
|
||||||
|
}
|
||||||
|
|
||||||
class StoreSettingsDto {
|
class StoreSettingsDto {
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
@@ -182,6 +195,22 @@ class StoreSettingsDto {
|
|||||||
@ValidateNested()
|
@ValidateNested()
|
||||||
@Type(() => EPaymentSettingsDto)
|
@Type(() => EPaymentSettingsDto)
|
||||||
ePayment?: EPaymentSettingsDto;
|
ePayment?: EPaymentSettingsDto;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
staticShippingEnabled?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsNumber({ maxDecimalPlaces: 2 })
|
||||||
|
@Min(0)
|
||||||
|
staticShippingAmount?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => StaticShippingProvinceExceptionDto)
|
||||||
|
staticShippingProvinceExceptions?: StaticShippingProvinceExceptionDto[];
|
||||||
}
|
}
|
||||||
|
|
||||||
class ModulesSettingsDto {
|
class ModulesSettingsDto {
|
||||||
|
|||||||
@@ -3,11 +3,18 @@ import { AuthModule } from '../auth/auth.module';
|
|||||||
import { BusinessSettingsModule } from '../business-settings/business-settings.module';
|
import { BusinessSettingsModule } from '../business-settings/business-settings.module';
|
||||||
import { OrdersModule } from '../orders/orders.module';
|
import { OrdersModule } from '../orders/orders.module';
|
||||||
import { PaymentsModule } from '../payments/payments.module';
|
import { PaymentsModule } from '../payments/payments.module';
|
||||||
|
import { StoreModule } from '../store/store.module';
|
||||||
import { CartController } from './cart.controller';
|
import { CartController } from './cart.controller';
|
||||||
import { CartService } from './cart.service';
|
import { CartService } from './cart.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [AuthModule, OrdersModule, PaymentsModule, BusinessSettingsModule],
|
imports: [
|
||||||
|
AuthModule,
|
||||||
|
OrdersModule,
|
||||||
|
PaymentsModule,
|
||||||
|
BusinessSettingsModule,
|
||||||
|
StoreModule,
|
||||||
|
],
|
||||||
controllers: [CartController],
|
controllers: [CartController],
|
||||||
providers: [CartService],
|
providers: [CartService],
|
||||||
})
|
})
|
||||||
|
|||||||
+114
-10
@@ -20,6 +20,7 @@ import { TransactionType } from '@prisma/client';
|
|||||||
import { BusinessSettingsService } from '../business-settings/business-settings.service';
|
import { BusinessSettingsService } from '../business-settings/business-settings.service';
|
||||||
import type { PaymentGatewayId } from '../business-settings/business-settings.types';
|
import type { PaymentGatewayId } from '../business-settings/business-settings.types';
|
||||||
import { listPublicPaymentGateways } from '../business-settings/business-settings.util';
|
import { listPublicPaymentGateways } from '../business-settings/business-settings.util';
|
||||||
|
import { ShippingCostGroupsService } from '../store/shipping-cost-groups.service';
|
||||||
|
|
||||||
const cartVariantInclude = {
|
const cartVariantInclude = {
|
||||||
storeItem: {
|
storeItem: {
|
||||||
@@ -59,6 +60,7 @@ export class CartService {
|
|||||||
private readonly orders: OrdersService,
|
private readonly orders: OrdersService,
|
||||||
private readonly payments: PaymentsService,
|
private readonly payments: PaymentsService,
|
||||||
private readonly businessSettings: BusinessSettingsService,
|
private readonly businessSettings: BusinessSettingsService,
|
||||||
|
private readonly shippingCostGroups: ShippingCostGroupsService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async getCart(businessIdRaw: string, actor: AuthUser) {
|
async getCart(businessIdRaw: string, actor: AuthUser) {
|
||||||
@@ -66,7 +68,7 @@ export class CartService {
|
|||||||
await this.assertCustomerAccess(businessId, actor);
|
await this.assertCustomerAccess(businessId, actor);
|
||||||
|
|
||||||
const cart = await this.getOrCreateCart(businessId, actor.id);
|
const cart = await this.getOrCreateCart(businessId, actor.id);
|
||||||
return { cart: this.serializeCart(cart) };
|
return { cart: await this.serializeCart(cart) };
|
||||||
}
|
}
|
||||||
|
|
||||||
async addItem(businessIdRaw: string, dto: AddCartItemDto, actor: AuthUser) {
|
async addItem(businessIdRaw: string, dto: AddCartItemDto, actor: AuthUser) {
|
||||||
@@ -97,7 +99,7 @@ export class CartService {
|
|||||||
const refreshed = await this.loadCart(cart.id);
|
const refreshed = await this.loadCart(cart.id);
|
||||||
return {
|
return {
|
||||||
message: 'Cart item quantity updated',
|
message: 'Cart item quantity updated',
|
||||||
cart: this.serializeCart(refreshed),
|
cart: await this.serializeCart(refreshed),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,7 +116,7 @@ export class CartService {
|
|||||||
const refreshed = await this.loadCart(cart.id);
|
const refreshed = await this.loadCart(cart.id);
|
||||||
return {
|
return {
|
||||||
message: 'Item added to cart',
|
message: 'Item added to cart',
|
||||||
cart: this.serializeCart(refreshed),
|
cart: await this.serializeCart(refreshed),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,7 +147,7 @@ export class CartService {
|
|||||||
const refreshed = await this.loadCart(cart.id);
|
const refreshed = await this.loadCart(cart.id);
|
||||||
return {
|
return {
|
||||||
message: 'Cart item updated',
|
message: 'Cart item updated',
|
||||||
cart: this.serializeCart(refreshed),
|
cart: await this.serializeCart(refreshed),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,7 +168,7 @@ export class CartService {
|
|||||||
const refreshed = await this.loadCart(cart.id);
|
const refreshed = await this.loadCart(cart.id);
|
||||||
return {
|
return {
|
||||||
message: 'Cart item removed',
|
message: 'Cart item removed',
|
||||||
cart: this.serializeCart(refreshed),
|
cart: await this.serializeCart(refreshed),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,7 +182,7 @@ export class CartService {
|
|||||||
const refreshed = await this.loadCart(cart.id);
|
const refreshed = await this.loadCart(cart.id);
|
||||||
return {
|
return {
|
||||||
message: 'Cart cleared',
|
message: 'Cart cleared',
|
||||||
cart: this.serializeCart(refreshed),
|
cart: await this.serializeCart(refreshed),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -204,6 +206,71 @@ export class CartService {
|
|||||||
dto.shippingAddress,
|
dto.shippingAddress,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const deliveryMode =
|
||||||
|
dto.deliveryMode === 'pickup' || dto.deliveryMode === 'delivery'
|
||||||
|
? dto.deliveryMode
|
||||||
|
: dto.addressId
|
||||||
|
? 'delivery'
|
||||||
|
: 'pickup';
|
||||||
|
|
||||||
|
const selectedMethodsByGroupId: Record<string, string> = {};
|
||||||
|
for (const group of dto.shippingGroups ?? []) {
|
||||||
|
if (group.methodId?.trim()) {
|
||||||
|
selectedMethodsByGroupId[group.groupId] = group.methodId.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const productIds = [
|
||||||
|
...new Set(
|
||||||
|
cart.items.map((item) => item.storeItemVariant.storeItem.product.id),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
const assignments =
|
||||||
|
productIds.length === 0
|
||||||
|
? []
|
||||||
|
: await this.prisma.categoryAssignment.findMany({
|
||||||
|
where: {
|
||||||
|
businessId,
|
||||||
|
entityType: 'product',
|
||||||
|
entityId: { in: productIds },
|
||||||
|
},
|
||||||
|
select: { entityId: true, categoryId: true },
|
||||||
|
});
|
||||||
|
const categoryIdsByProduct = new Map<string, string[]>();
|
||||||
|
for (const row of assignments) {
|
||||||
|
const key = row.entityId.toString();
|
||||||
|
const list = categoryIdsByProduct.get(key) ?? [];
|
||||||
|
list.push(row.categoryId.toString());
|
||||||
|
categoryIdsByProduct.set(key, list);
|
||||||
|
}
|
||||||
|
|
||||||
|
const shippingQuote = await this.shippingCostGroups.quoteForCheckout({
|
||||||
|
businessId,
|
||||||
|
deliveryMode,
|
||||||
|
provinceName:
|
||||||
|
deliveryMode === 'delivery' ? String(shippingAddress.province ?? '') : null,
|
||||||
|
cartItems: cart.items.map((item) => {
|
||||||
|
const product = item.storeItemVariant.storeItem.product;
|
||||||
|
const content = this.asRecord(product.content);
|
||||||
|
const nameFa = (content.nameFa as string | null | undefined)?.trim();
|
||||||
|
return {
|
||||||
|
productName: nameFa || product.title,
|
||||||
|
categoryIds:
|
||||||
|
categoryIdsByProduct.get(product.id.toString()) ?? [],
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
selectedMethodsByGroupId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const enrichedShippingAddress = {
|
||||||
|
...shippingAddress,
|
||||||
|
deliveryMode,
|
||||||
|
shipping: {
|
||||||
|
total: shippingQuote.total,
|
||||||
|
groups: shippingQuote.groups,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
const isEPayment = dto.payment.type === TransactionType.e_payment_gate;
|
const isEPayment = dto.payment.type === TransactionType.e_payment_gate;
|
||||||
let gatewayType: PaymentGatewayId | undefined;
|
let gatewayType: PaymentGatewayId | undefined;
|
||||||
|
|
||||||
@@ -234,7 +301,8 @@ export class CartService {
|
|||||||
createdBy: actor.id,
|
createdBy: actor.id,
|
||||||
source: 'website',
|
source: 'website',
|
||||||
cartItems: cart.items,
|
cartItems: cart.items,
|
||||||
shippingAddress,
|
shippingAddress: enrichedShippingAddress,
|
||||||
|
shippingTotal: shippingQuote.total,
|
||||||
addressId: dto.addressId ? BigInt(dto.addressId) : null,
|
addressId: dto.addressId ? BigInt(dto.addressId) : null,
|
||||||
customerNotes: dto.customerNotes?.trim() || null,
|
customerNotes: dto.customerNotes?.trim() || null,
|
||||||
adminNotes: null,
|
adminNotes: null,
|
||||||
@@ -393,8 +461,40 @@ export class CartService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private serializeCart(cart: CartWithItems) {
|
private async serializeCart(cart: CartWithItems) {
|
||||||
const items = cart.items.map((item) => this.serializeCartItem(item));
|
const productIds = [
|
||||||
|
...new Set(
|
||||||
|
cart.items.map((item) => item.storeItemVariant.storeItem.product.id),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
const assignments =
|
||||||
|
productIds.length === 0
|
||||||
|
? []
|
||||||
|
: await this.prisma.categoryAssignment.findMany({
|
||||||
|
where: {
|
||||||
|
businessId: cart.businessId,
|
||||||
|
entityType: 'product',
|
||||||
|
entityId: { in: productIds },
|
||||||
|
},
|
||||||
|
select: { entityId: true, categoryId: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const categoryIdsByProduct = new Map<string, string[]>();
|
||||||
|
for (const row of assignments) {
|
||||||
|
const key = row.entityId.toString();
|
||||||
|
const list = categoryIdsByProduct.get(key) ?? [];
|
||||||
|
list.push(row.categoryId.toString());
|
||||||
|
categoryIdsByProduct.set(key, list);
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = cart.items.map((item) => {
|
||||||
|
const productId = item.storeItemVariant.storeItem.product.id.toString();
|
||||||
|
return this.serializeCartItem(
|
||||||
|
item,
|
||||||
|
categoryIdsByProduct.get(productId) ?? [],
|
||||||
|
);
|
||||||
|
});
|
||||||
const subtotal = items.reduce((sum, item) => sum + item.lineTotal, 0);
|
const subtotal = items.reduce((sum, item) => sum + item.lineTotal, 0);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -407,7 +507,10 @@ export class CartService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private serializeCartItem(item: CartWithItems['items'][number]) {
|
private serializeCartItem(
|
||||||
|
item: CartWithItems['items'][number],
|
||||||
|
categoryIds: string[],
|
||||||
|
) {
|
||||||
const variant = item.storeItemVariant;
|
const variant = item.storeItemVariant;
|
||||||
const product = variant.storeItem.product;
|
const product = variant.storeItem.product;
|
||||||
const content = this.asRecord(product.content);
|
const content = this.asRecord(product.content);
|
||||||
@@ -431,6 +534,7 @@ export class CartService {
|
|||||||
productTitle: product.title,
|
productTitle: product.title,
|
||||||
productNameFa: (content.nameFa as string | null | undefined) ?? '',
|
productNameFa: (content.nameFa as string | null | undefined) ?? '',
|
||||||
productImage: product.featuredMedia?.publicUrl ?? null,
|
productImage: product.featuredMedia?.publicUrl ?? null,
|
||||||
|
categoryIds,
|
||||||
sku: variant.sku,
|
sku: variant.sku,
|
||||||
selections,
|
selections,
|
||||||
label: selections.map((entry) => entry.value).join(' · ') || product.title,
|
label: selections.map((entry) => entry.value).join(' · ') || product.title,
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import {
|
import {
|
||||||
|
IsArray,
|
||||||
|
IsIn,
|
||||||
IsInt,
|
IsInt,
|
||||||
|
IsNumber,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsString,
|
IsString,
|
||||||
IsUrl,
|
IsUrl,
|
||||||
@@ -53,6 +56,35 @@ export class ShippingAddressDto {
|
|||||||
landline?: string;
|
landline?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class CheckoutShippingGroupDto {
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
groupId!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
title?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
methodId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
methodLabel?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsNumber({ maxDecimalPlaces: 2 })
|
||||||
|
@Min(0)
|
||||||
|
amount?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
itemNames?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export class CheckoutCartDto {
|
export class CheckoutCartDto {
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@@ -64,6 +96,16 @@ export class CheckoutCartDto {
|
|||||||
@Type(() => ShippingAddressDto)
|
@Type(() => ShippingAddressDto)
|
||||||
shippingAddress?: ShippingAddressDto;
|
shippingAddress?: ShippingAddressDto;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(['delivery', 'pickup'])
|
||||||
|
deliveryMode?: 'delivery' | 'pickup';
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => CheckoutShippingGroupDto)
|
||||||
|
shippingGroups?: CheckoutShippingGroupDto[];
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
customerNotes?: string;
|
customerNotes?: string;
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
/**
|
||||||
|
* Heuristics to reject obvious bot / gibberish contact submissions
|
||||||
|
* before they are stored or trigger SMS.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Single Latin token with chaotic mixed case (e.g. nuKnGmIVvgMoplagJYbJHIf). */
|
||||||
|
export function looksLikeRandomToken(value: string): boolean {
|
||||||
|
const s = value.trim();
|
||||||
|
if (s.length < 12) return false;
|
||||||
|
if (!/^[A-Za-z]+$/.test(s)) return false;
|
||||||
|
|
||||||
|
// Normal Title Case word: "Something"
|
||||||
|
if (/^[A-Z][a-z]+$/.test(s)) return false;
|
||||||
|
// ALL CAPS short-ish brand-like tokens are less common at this length
|
||||||
|
if (/^[A-Z]+$/.test(s) && s.length < 16) return false;
|
||||||
|
// all lowercase long word without spaces is still suspicious when very long
|
||||||
|
if (/^[a-z]+$/.test(s)) return s.length >= 18;
|
||||||
|
|
||||||
|
let caseSwitches = 0;
|
||||||
|
for (let i = 1; i < s.length; i++) {
|
||||||
|
const prevUpper = s[i - 1]! >= 'A' && s[i - 1]! <= 'Z';
|
||||||
|
const curUpper = s[i]! >= 'A' && s[i]! <= 'Z';
|
||||||
|
if (prevUpper !== curUpper) caseSwitches += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Random camelNoise tends to flip case often
|
||||||
|
if (caseSwitches >= 5) return true;
|
||||||
|
if (caseSwitches >= 3 && caseSwitches / (s.length - 1) >= 0.28) return true;
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when the submission looks like spam and should be rejected. */
|
||||||
|
export function isLikelySpamContactSubmission(input: {
|
||||||
|
title: string;
|
||||||
|
name: string;
|
||||||
|
text: string;
|
||||||
|
}): boolean {
|
||||||
|
const title = input.title.trim();
|
||||||
|
const name = input.name.trim();
|
||||||
|
const text = input.text.trim();
|
||||||
|
|
||||||
|
if (looksLikeRandomToken(name)) return true;
|
||||||
|
|
||||||
|
const gibberishFields = [title, name, text].filter((v) =>
|
||||||
|
looksLikeRandomToken(v),
|
||||||
|
).length;
|
||||||
|
if (gibberishFields >= 2) return true;
|
||||||
|
|
||||||
|
// Entire payload is one unbroken Latin blob with no spaces / Persian
|
||||||
|
const combined = `${title} ${name} ${text}`;
|
||||||
|
const hasPersian = /[\u0600-\u06FF]/.test(combined);
|
||||||
|
const hasSpace = /\s/.test(name) || /\s/.test(text);
|
||||||
|
if (
|
||||||
|
!hasPersian &&
|
||||||
|
!hasSpace &&
|
||||||
|
/^[A-Za-z0-9]+$/.test(name) &&
|
||||||
|
name.length >= 14 &&
|
||||||
|
text.length >= 14 &&
|
||||||
|
/^[A-Za-z0-9]+$/.test(text)
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
BadRequestException,
|
||||||
ForbiddenException,
|
ForbiddenException,
|
||||||
Injectable,
|
Injectable,
|
||||||
Logger,
|
Logger,
|
||||||
@@ -9,9 +10,10 @@ import { AuthUser } from '../auth/auth.types';
|
|||||||
import { PermissionsService } from '../auth/permissions.service';
|
import { PermissionsService } from '../auth/permissions.service';
|
||||||
import { SmsBillingService } from '../auth/sms-billing.service';
|
import { SmsBillingService } from '../auth/sms-billing.service';
|
||||||
import type { SmsSendType } from '../auth/sms-billing';
|
import type { SmsSendType } from '../auth/sms-billing';
|
||||||
import { SmsService, toGamaMsisdn } from '../auth/sms.service';
|
import { SmsService, toIranianMobileMsisdn } from '../auth/sms.service';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { TenantService } from '../tenant/tenant.service';
|
import { TenantService } from '../tenant/tenant.service';
|
||||||
|
import { isLikelySpamContactSubmission } from './contact-submission-spam';
|
||||||
import { CreateContactSubmissionDto } from './dto/create-contact-submission.dto';
|
import { CreateContactSubmissionDto } from './dto/create-contact-submission.dto';
|
||||||
import { ListContactSubmissionsDto } from './dto/list-contact-submissions.dto';
|
import { ListContactSubmissionsDto } from './dto/list-contact-submissions.dto';
|
||||||
|
|
||||||
@@ -40,14 +42,27 @@ export class ContactSubmissionsService {
|
|||||||
async createPublic(host: string, dto: CreateContactSubmissionDto) {
|
async createPublic(host: string, dto: CreateContactSubmissionDto) {
|
||||||
const business = await this.tenant.resolveBusinessByDomain(host);
|
const business = await this.tenant.resolveBusinessByDomain(host);
|
||||||
|
|
||||||
|
const title = dto.title.trim();
|
||||||
|
const name = dto.name.trim();
|
||||||
|
const text = dto.text.trim();
|
||||||
|
const email = dto.email?.trim() || null;
|
||||||
|
const cellNumber = dto.cellNumber?.trim() || null;
|
||||||
|
|
||||||
|
if (isLikelySpamContactSubmission({ title, name, text })) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Contact form rejected as spam for business ${business.id}: name="${name}"`,
|
||||||
|
);
|
||||||
|
throw new BadRequestException('Invalid submission');
|
||||||
|
}
|
||||||
|
|
||||||
const created = await this.prisma.contactSubmission.create({
|
const created = await this.prisma.contactSubmission.create({
|
||||||
data: {
|
data: {
|
||||||
businessId: business.id,
|
businessId: business.id,
|
||||||
title: dto.title.trim(),
|
title,
|
||||||
name: dto.name.trim(),
|
name,
|
||||||
email: dto.email?.trim() || null,
|
email,
|
||||||
cellNumber: dto.cellNumber?.trim() || null,
|
cellNumber,
|
||||||
text: dto.text.trim(),
|
text,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -69,7 +84,7 @@ export class ContactSubmissionsService {
|
|||||||
/**
|
/**
|
||||||
* Best-effort SMS via Meshkee service (main) line:
|
* Best-effort SMS via Meshkee service (main) line:
|
||||||
* 1) business owner — new contact named in the message
|
* 1) business owner — new contact named in the message
|
||||||
* 2) contacter — confirmation, only when their cell number is valid
|
* 2) contacter — confirmation, only for a valid Iranian mobile
|
||||||
* Never fails the contact form submission.
|
* Never fails the contact form submission.
|
||||||
*/
|
*/
|
||||||
private async notifyContactSubmissionSms(input: {
|
private async notifyContactSubmissionSms(input: {
|
||||||
@@ -92,7 +107,7 @@ export class ContactSubmissionsService {
|
|||||||
label: 'owner',
|
label: 'owner',
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!toGamaMsisdn(input.contacterCellNumber ?? '')) {
|
if (!toIranianMobileMsisdn(input.contacterCellNumber ?? '')) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,9 +144,9 @@ export class ContactSubmissionsService {
|
|||||||
label: string;
|
label: string;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
const cellNumber = input.cellNumber?.trim();
|
const cellNumber = input.cellNumber?.trim();
|
||||||
if (!cellNumber || !toGamaMsisdn(cellNumber)) {
|
if (!cellNumber || !toIranianMobileMsisdn(cellNumber)) {
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
`Contact form ${input.label} SMS skipped for business ${input.businessId}: invalid or missing cellphone`,
|
`Contact form ${input.label} SMS skipped for business ${input.businessId}: not a valid Iranian mobile`,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -376,6 +376,7 @@ export class OrdersService {
|
|||||||
source: OrderSource;
|
source: OrderSource;
|
||||||
cartItems: CartItemForOrder[];
|
cartItems: CartItemForOrder[];
|
||||||
shippingAddress: Record<string, unknown>;
|
shippingAddress: Record<string, unknown>;
|
||||||
|
shippingTotal?: number;
|
||||||
addressId: bigint | null;
|
addressId: bigint | null;
|
||||||
customerNotes: string | null;
|
customerNotes: string | null;
|
||||||
adminNotes: string | null;
|
adminNotes: string | null;
|
||||||
@@ -390,7 +391,10 @@ export class OrdersService {
|
|||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
|
|
||||||
const orderTotal = preparedItems.reduce((sum, item) => sum + item.lineTotal, 0);
|
const subtotal = preparedItems.reduce((sum, item) => sum + item.lineTotal, 0);
|
||||||
|
const shippingTotal = Math.max(0, Number(input.shippingTotal ?? 0));
|
||||||
|
const discountTotal = 0;
|
||||||
|
const orderTotal = subtotal + shippingTotal - discountTotal;
|
||||||
|
|
||||||
return this.createOrder({
|
return this.createOrder({
|
||||||
businessId: input.businessId,
|
businessId: input.businessId,
|
||||||
@@ -399,6 +403,8 @@ export class OrdersService {
|
|||||||
source: input.source,
|
source: input.source,
|
||||||
items: preparedItems,
|
items: preparedItems,
|
||||||
shippingAddress: input.shippingAddress,
|
shippingAddress: input.shippingAddress,
|
||||||
|
shippingTotal,
|
||||||
|
discountTotal,
|
||||||
addressId: input.addressId,
|
addressId: input.addressId,
|
||||||
customerNotes: input.customerNotes,
|
customerNotes: input.customerNotes,
|
||||||
adminNotes: input.adminNotes,
|
adminNotes: input.adminNotes,
|
||||||
@@ -419,6 +425,8 @@ export class OrdersService {
|
|||||||
source: OrderSource;
|
source: OrderSource;
|
||||||
items: PreparedOrderItem[];
|
items: PreparedOrderItem[];
|
||||||
shippingAddress: Record<string, unknown>;
|
shippingAddress: Record<string, unknown>;
|
||||||
|
shippingTotal?: number;
|
||||||
|
discountTotal?: number;
|
||||||
addressId: bigint | null;
|
addressId: bigint | null;
|
||||||
customerNotes: string | null;
|
customerNotes: string | null;
|
||||||
adminNotes: string | null;
|
adminNotes: string | null;
|
||||||
@@ -426,8 +434,8 @@ export class OrdersService {
|
|||||||
payments?: TransactionPaymentInput[];
|
payments?: TransactionPaymentInput[];
|
||||||
}) {
|
}) {
|
||||||
const subtotal = input.items.reduce((sum, item) => sum + item.lineTotal, 0);
|
const subtotal = input.items.reduce((sum, item) => sum + item.lineTotal, 0);
|
||||||
const shippingTotal = 0;
|
const shippingTotal = Math.max(0, Number(input.shippingTotal ?? 0));
|
||||||
const discountTotal = 0;
|
const discountTotal = Math.max(0, Number(input.discountTotal ?? 0));
|
||||||
const total = subtotal + shippingTotal - discountTotal;
|
const total = subtotal + shippingTotal - discountTotal;
|
||||||
const processStepId = await this.defaultProcessStepId(input.businessId);
|
const processStepId = await this.defaultProcessStepId(input.businessId);
|
||||||
|
|
||||||
|
|||||||
@@ -303,6 +303,8 @@ export class PaymentsService {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await this.advanceOrderAfterPayment(businessId, transaction.orderId);
|
||||||
|
|
||||||
// Keep order as pending for fulfillment; payment completion is on the transaction.
|
// Keep order as pending for fulfillment; payment completion is on the transaction.
|
||||||
if (!returnUrl) {
|
if (!returnUrl) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
@@ -392,6 +394,27 @@ export class PaymentsService {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async advanceOrderAfterPayment(
|
||||||
|
businessId: bigint,
|
||||||
|
orderId: bigint,
|
||||||
|
) {
|
||||||
|
const steps = await this.settings.getOrderProcessSteps(businessId);
|
||||||
|
const paymentSuccessful = steps.find((step) => step.id === 'payment-successful');
|
||||||
|
if (!paymentSuccessful) return;
|
||||||
|
|
||||||
|
await this.prisma.order.updateMany({
|
||||||
|
where: {
|
||||||
|
id: orderId,
|
||||||
|
businessId,
|
||||||
|
processStepId: { in: ['awaiting-payment', steps[0]?.id].filter(Boolean) as string[] },
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
processStepId: 'payment-successful',
|
||||||
|
status: 'confirmed',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private async markFailed(transactionId: bigint, meta: GatewayMeta) {
|
private async markFailed(transactionId: bigint, meta: GatewayMeta) {
|
||||||
await this.prisma.transaction.update({
|
await this.prisma.transaction.update({
|
||||||
where: { id: transactionId },
|
where: { id: transactionId },
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import {
|
||||||
|
ArrayMinSize,
|
||||||
|
IsArray,
|
||||||
|
IsBoolean,
|
||||||
|
IsIn,
|
||||||
|
IsInt,
|
||||||
|
IsNumber,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
MaxLength,
|
||||||
|
Min,
|
||||||
|
MinLength,
|
||||||
|
ValidateNested,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
export const SHIPPING_METHOD_IDS = [
|
||||||
|
'post',
|
||||||
|
'tipax',
|
||||||
|
'mahex',
|
||||||
|
'freight',
|
||||||
|
'air',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type ShippingMethodId = (typeof SHIPPING_METHOD_IDS)[number];
|
||||||
|
|
||||||
|
export class ListShippingCostGroupsDto {
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
page?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
pageSize?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Boolean)
|
||||||
|
@IsBoolean()
|
||||||
|
isActive?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ShippingProvinceExceptionDto {
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
provinceId!: string;
|
||||||
|
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsNumber({ maxDecimalPlaces: 2 })
|
||||||
|
@Min(0)
|
||||||
|
amount!: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CreateShippingCostGroupDto {
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
@MaxLength(255)
|
||||||
|
nameFa!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
@MaxLength(255)
|
||||||
|
nameEn!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
@IsArray()
|
||||||
|
@ArrayMinSize(1)
|
||||||
|
@IsString({ each: true })
|
||||||
|
@IsIn([...SHIPPING_METHOD_IDS], { each: true })
|
||||||
|
methods!: ShippingMethodId[];
|
||||||
|
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsNumber({ maxDecimalPlaces: 2 })
|
||||||
|
@Min(0)
|
||||||
|
defaultAmount!: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
categoryIds?: string[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => ShippingProvinceExceptionDto)
|
||||||
|
provinceExceptions?: ShippingProvinceExceptionDto[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
sortOrder?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
isActive?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
isDefault?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdateShippingCostGroupDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
@MaxLength(255)
|
||||||
|
nameFa?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
@MaxLength(255)
|
||||||
|
nameEn?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
description?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@ArrayMinSize(1)
|
||||||
|
@IsString({ each: true })
|
||||||
|
@IsIn([...SHIPPING_METHOD_IDS], { each: true })
|
||||||
|
methods?: ShippingMethodId[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsNumber({ maxDecimalPlaces: 2 })
|
||||||
|
@Min(0)
|
||||||
|
defaultAmount?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
categoryIds?: string[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => ShippingProvinceExceptionDto)
|
||||||
|
provinceExceptions?: ShippingProvinceExceptionDto[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
sortOrder?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
isActive?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
isDefault?: boolean;
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { AuthUser } from '../auth/auth.types';
|
||||||
|
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||||
|
import { RequireBusinessPermission } from '../auth/decorators/require-business-permission.decorator';
|
||||||
|
import { BusinessPermissionGuard } from '../auth/guards/business-permission.guard';
|
||||||
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
|
import {
|
||||||
|
CreateShippingCostGroupDto,
|
||||||
|
ListShippingCostGroupsDto,
|
||||||
|
UpdateShippingCostGroupDto,
|
||||||
|
} from './dto/shipping-cost-groups.dto';
|
||||||
|
import { ShippingCostGroupsService } from './shipping-cost-groups.service';
|
||||||
|
|
||||||
|
@Controller('tenants/:host/shipping-cost-groups')
|
||||||
|
export class PublicShippingCostGroupsController {
|
||||||
|
constructor(private readonly service: ShippingCostGroupsService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
list(@Param('host') host: string) {
|
||||||
|
return this.service.listPublic(host);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Controller('businesses/:businessId/shipping-cost-groups')
|
||||||
|
@UseGuards(JwtAuthGuard, BusinessPermissionGuard)
|
||||||
|
export class ShippingCostGroupsController {
|
||||||
|
constructor(private readonly service: ShippingCostGroupsService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@RequireBusinessPermission('products.read')
|
||||||
|
list(
|
||||||
|
@Param('businessId') businessId: string,
|
||||||
|
@Query() query: ListShippingCostGroupsDto,
|
||||||
|
@CurrentUser() user: AuthUser,
|
||||||
|
) {
|
||||||
|
return this.service.list(businessId, query, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':groupId')
|
||||||
|
@RequireBusinessPermission('products.read')
|
||||||
|
getOne(
|
||||||
|
@Param('businessId') businessId: string,
|
||||||
|
@Param('groupId') groupId: string,
|
||||||
|
@CurrentUser() user: AuthUser,
|
||||||
|
) {
|
||||||
|
return this.service.getOne(businessId, groupId, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@RequireBusinessPermission('products.update')
|
||||||
|
create(
|
||||||
|
@Param('businessId') businessId: string,
|
||||||
|
@Body() dto: CreateShippingCostGroupDto,
|
||||||
|
@CurrentUser() user: AuthUser,
|
||||||
|
) {
|
||||||
|
return this.service.create(businessId, dto, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':groupId')
|
||||||
|
@RequireBusinessPermission('products.update')
|
||||||
|
update(
|
||||||
|
@Param('businessId') businessId: string,
|
||||||
|
@Param('groupId') groupId: string,
|
||||||
|
@Body() dto: UpdateShippingCostGroupDto,
|
||||||
|
@CurrentUser() user: AuthUser,
|
||||||
|
) {
|
||||||
|
return this.service.update(businessId, groupId, dto, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':groupId')
|
||||||
|
@RequireBusinessPermission('products.update')
|
||||||
|
remove(
|
||||||
|
@Param('businessId') businessId: string,
|
||||||
|
@Param('groupId') groupId: string,
|
||||||
|
@CurrentUser() user: AuthUser,
|
||||||
|
) {
|
||||||
|
return this.service.remove(businessId, groupId, user);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,698 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
ForbiddenException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
import { AuthUser } from '../auth/auth.types';
|
||||||
|
import { PermissionsService } from '../auth/permissions.service';
|
||||||
|
import { normalizeBusinessSettings } from '../business-settings/business-settings.util';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { TenantService } from '../tenant/tenant.service';
|
||||||
|
import {
|
||||||
|
CreateShippingCostGroupDto,
|
||||||
|
ListShippingCostGroupsDto,
|
||||||
|
SHIPPING_METHOD_IDS,
|
||||||
|
type ShippingMethodId,
|
||||||
|
UpdateShippingCostGroupDto,
|
||||||
|
} from './dto/shipping-cost-groups.dto';
|
||||||
|
|
||||||
|
type GroupWithRelations = Prisma.ShippingCostGroupGetPayload<{
|
||||||
|
include: {
|
||||||
|
categories: {
|
||||||
|
include: {
|
||||||
|
category: {
|
||||||
|
select: { id: true; name: true; nameFa: true; slug: true };
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
cityRates: true;
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
|
||||||
|
const METHOD_SET = new Set<string>(SHIPPING_METHOD_IDS);
|
||||||
|
|
||||||
|
const STORE_SHIPPING_TITLE = 'ارسال توسط مجموعه';
|
||||||
|
|
||||||
|
const METHOD_LABELS_FA: Record<ShippingMethodId, string> = {
|
||||||
|
post: 'پست',
|
||||||
|
tipax: 'تیپاکس',
|
||||||
|
mahex: 'ماهکس',
|
||||||
|
freight: 'باربری',
|
||||||
|
air: 'ارسال هوایی',
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ShippingCostGroupsService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly permissions: PermissionsService,
|
||||||
|
private readonly tenant: TenantService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async list(
|
||||||
|
businessIdRaw: string,
|
||||||
|
query: ListShippingCostGroupsDto,
|
||||||
|
actor: AuthUser,
|
||||||
|
) {
|
||||||
|
const businessId = BigInt(businessIdRaw);
|
||||||
|
await this.assertPermission(businessId, actor.id, 'products.read');
|
||||||
|
await this.assertStoreModuleEnabled(businessId);
|
||||||
|
|
||||||
|
const page = query.page ?? 1;
|
||||||
|
const pageSize = query.pageSize ?? 50;
|
||||||
|
const skip = (page - 1) * pageSize;
|
||||||
|
|
||||||
|
const where: Prisma.ShippingCostGroupWhereInput = {
|
||||||
|
businessId,
|
||||||
|
...(query.isActive !== undefined ? { isActive: query.isActive } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const [items, total] = await Promise.all([
|
||||||
|
this.prisma.shippingCostGroup.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: [{ isDefault: 'desc' }, { sortOrder: 'asc' }, { createdAt: 'desc' }],
|
||||||
|
skip,
|
||||||
|
take: pageSize,
|
||||||
|
include: this.groupInclude(),
|
||||||
|
}),
|
||||||
|
this.prisma.shippingCostGroup.count({ where }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: items.map((item) => this.serialize(item)),
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getOne(
|
||||||
|
businessIdRaw: string,
|
||||||
|
groupIdRaw: string,
|
||||||
|
actor: AuthUser,
|
||||||
|
) {
|
||||||
|
const businessId = BigInt(businessIdRaw);
|
||||||
|
const groupId = BigInt(groupIdRaw);
|
||||||
|
await this.assertPermission(businessId, actor.id, 'products.read');
|
||||||
|
await this.assertStoreModuleEnabled(businessId);
|
||||||
|
|
||||||
|
const group = await this.findOrThrow(businessId, groupId);
|
||||||
|
return { group: this.serialize(group) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(
|
||||||
|
businessIdRaw: string,
|
||||||
|
dto: CreateShippingCostGroupDto,
|
||||||
|
actor: AuthUser,
|
||||||
|
) {
|
||||||
|
const businessId = BigInt(businessIdRaw);
|
||||||
|
await this.assertPermission(businessId, actor.id, 'products.update');
|
||||||
|
await this.assertStoreModuleEnabled(businessId);
|
||||||
|
|
||||||
|
const methods = this.normalizeMethods(dto.methods);
|
||||||
|
const categoryIds = await this.resolveCategoryIds(
|
||||||
|
businessId,
|
||||||
|
dto.categoryIds ?? [],
|
||||||
|
);
|
||||||
|
const provinceIds = await this.resolveProvinceIds(
|
||||||
|
dto.provinceExceptions ?? [],
|
||||||
|
);
|
||||||
|
const isDefault = dto.isDefault === true;
|
||||||
|
|
||||||
|
const created = await this.prisma.$transaction(async (tx) => {
|
||||||
|
if (isDefault) {
|
||||||
|
await tx.shippingCostGroup.updateMany({
|
||||||
|
where: { businessId, isDefault: true },
|
||||||
|
data: { isDefault: false },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return tx.shippingCostGroup.create({
|
||||||
|
data: {
|
||||||
|
businessId,
|
||||||
|
nameFa: dto.nameFa.trim(),
|
||||||
|
nameEn: dto.nameEn.trim(),
|
||||||
|
description: dto.description?.trim() || null,
|
||||||
|
methods,
|
||||||
|
defaultAmount: dto.defaultAmount,
|
||||||
|
sortOrder: dto.sortOrder ?? 0,
|
||||||
|
isActive: dto.isActive ?? true,
|
||||||
|
isDefault,
|
||||||
|
...(categoryIds.length
|
||||||
|
? {
|
||||||
|
categories: {
|
||||||
|
create: categoryIds.map((categoryId) => ({ categoryId })),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
...(provinceIds.length
|
||||||
|
? {
|
||||||
|
cityRates: {
|
||||||
|
create: provinceIds.map(({ provinceId, amount }) => ({
|
||||||
|
cityId: provinceId,
|
||||||
|
amount,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
include: this.groupInclude(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
message: 'Shipping cost group created successfully',
|
||||||
|
group: this.serialize(created),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(
|
||||||
|
businessIdRaw: string,
|
||||||
|
groupIdRaw: string,
|
||||||
|
dto: UpdateShippingCostGroupDto,
|
||||||
|
actor: AuthUser,
|
||||||
|
) {
|
||||||
|
const businessId = BigInt(businessIdRaw);
|
||||||
|
const groupId = BigInt(groupIdRaw);
|
||||||
|
await this.assertPermission(businessId, actor.id, 'products.update');
|
||||||
|
await this.assertStoreModuleEnabled(businessId);
|
||||||
|
|
||||||
|
await this.findOrThrow(businessId, groupId);
|
||||||
|
|
||||||
|
const data: Prisma.ShippingCostGroupUpdateInput = {};
|
||||||
|
if (dto.nameFa !== undefined) data.nameFa = dto.nameFa.trim();
|
||||||
|
if (dto.nameEn !== undefined) data.nameEn = dto.nameEn.trim();
|
||||||
|
if (dto.description !== undefined) {
|
||||||
|
data.description =
|
||||||
|
dto.description == null ? null : dto.description.trim() || null;
|
||||||
|
}
|
||||||
|
if (dto.methods !== undefined) {
|
||||||
|
data.methods = this.normalizeMethods(dto.methods);
|
||||||
|
}
|
||||||
|
if (dto.defaultAmount !== undefined) {
|
||||||
|
data.defaultAmount = dto.defaultAmount;
|
||||||
|
}
|
||||||
|
if (dto.sortOrder !== undefined) data.sortOrder = dto.sortOrder;
|
||||||
|
if (dto.isActive !== undefined) data.isActive = dto.isActive;
|
||||||
|
if (dto.isDefault !== undefined) data.isDefault = dto.isDefault;
|
||||||
|
|
||||||
|
if (dto.categoryIds !== undefined) {
|
||||||
|
const categoryIds = await this.resolveCategoryIds(
|
||||||
|
businessId,
|
||||||
|
dto.categoryIds,
|
||||||
|
);
|
||||||
|
data.categories = {
|
||||||
|
deleteMany: {},
|
||||||
|
...(categoryIds.length
|
||||||
|
? {
|
||||||
|
create: categoryIds.map((categoryId) => ({ categoryId })),
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dto.provinceExceptions !== undefined) {
|
||||||
|
const provinceIds = await this.resolveProvinceIds(dto.provinceExceptions);
|
||||||
|
data.cityRates = {
|
||||||
|
deleteMany: {},
|
||||||
|
...(provinceIds.length
|
||||||
|
? {
|
||||||
|
create: provinceIds.map(({ provinceId, amount }) => ({
|
||||||
|
cityId: provinceId,
|
||||||
|
amount,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await this.prisma.$transaction(async (tx) => {
|
||||||
|
if (dto.isDefault === true) {
|
||||||
|
await tx.shippingCostGroup.updateMany({
|
||||||
|
where: {
|
||||||
|
businessId,
|
||||||
|
isDefault: true,
|
||||||
|
id: { not: groupId },
|
||||||
|
},
|
||||||
|
data: { isDefault: false },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return tx.shippingCostGroup.update({
|
||||||
|
where: { id: groupId },
|
||||||
|
data,
|
||||||
|
include: this.groupInclude(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
message: 'Shipping cost group updated successfully',
|
||||||
|
group: this.serialize(updated),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(
|
||||||
|
businessIdRaw: string,
|
||||||
|
groupIdRaw: string,
|
||||||
|
actor: AuthUser,
|
||||||
|
) {
|
||||||
|
const businessId = BigInt(businessIdRaw);
|
||||||
|
const groupId = BigInt(groupIdRaw);
|
||||||
|
await this.assertPermission(businessId, actor.id, 'products.update');
|
||||||
|
await this.assertStoreModuleEnabled(businessId);
|
||||||
|
|
||||||
|
await this.findOrThrow(businessId, groupId);
|
||||||
|
await this.prisma.shippingCostGroup.delete({ where: { id: groupId } });
|
||||||
|
|
||||||
|
return { message: 'Shipping cost group deleted successfully' };
|
||||||
|
}
|
||||||
|
|
||||||
|
async listPublic(host: string) {
|
||||||
|
const business = await this.tenant.resolveBusinessByDomain(host);
|
||||||
|
const settings = normalizeBusinessSettings(business.settings);
|
||||||
|
if (!settings.modules.enabled.includes('store')) {
|
||||||
|
return {
|
||||||
|
enabled: false,
|
||||||
|
staticShippingEnabled: false,
|
||||||
|
staticShippingAmount: 0,
|
||||||
|
staticShippingProvinceExceptions: [],
|
||||||
|
items: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = await this.prisma.shippingCostGroup.findMany({
|
||||||
|
where: { businessId: business.id, isActive: true },
|
||||||
|
orderBy: [{ isDefault: 'desc' }, { sortOrder: 'asc' }, { createdAt: 'desc' }],
|
||||||
|
include: this.groupInclude(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
enabled: true,
|
||||||
|
staticShippingEnabled: settings.store.staticShippingEnabled,
|
||||||
|
staticShippingAmount: settings.store.staticShippingAmount,
|
||||||
|
staticShippingProvinceExceptions:
|
||||||
|
settings.store.staticShippingProvinceExceptions,
|
||||||
|
items: items.map((item) => this.serialize(item)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server-side shipping quote for checkout. Method ids from the client are only
|
||||||
|
* used when they belong to the matched group's methods (for labels).
|
||||||
|
*/
|
||||||
|
async quoteForCheckout(input: {
|
||||||
|
businessId: bigint;
|
||||||
|
deliveryMode: 'delivery' | 'pickup';
|
||||||
|
provinceName: string | null;
|
||||||
|
cartItems: Array<{
|
||||||
|
productName: string;
|
||||||
|
categoryIds: string[];
|
||||||
|
}>;
|
||||||
|
selectedMethodsByGroupId?: Record<string, string>;
|
||||||
|
}): Promise<{
|
||||||
|
total: number;
|
||||||
|
groups: Array<{
|
||||||
|
groupId: string;
|
||||||
|
title: string;
|
||||||
|
methodId: string | null;
|
||||||
|
methodLabel: string | null;
|
||||||
|
amount: number;
|
||||||
|
itemNames: string[];
|
||||||
|
}>;
|
||||||
|
}> {
|
||||||
|
if (input.deliveryMode === 'pickup' || input.cartItems.length === 0) {
|
||||||
|
return { total: 0, groups: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const business = await this.prisma.business.findUnique({
|
||||||
|
where: { id: input.businessId },
|
||||||
|
select: { settings: true },
|
||||||
|
});
|
||||||
|
if (!business) {
|
||||||
|
return {
|
||||||
|
total: 0,
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
groupId: 'ungrouped',
|
||||||
|
title: STORE_SHIPPING_TITLE,
|
||||||
|
methodId: null,
|
||||||
|
methodLabel: null,
|
||||||
|
amount: 0,
|
||||||
|
itemNames: input.cartItems.map((item) => item.productName),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const settings = normalizeBusinessSettings(business.settings);
|
||||||
|
if (!settings.modules.enabled.includes('store')) {
|
||||||
|
return {
|
||||||
|
total: 0,
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
groupId: 'ungrouped',
|
||||||
|
title: STORE_SHIPPING_TITLE,
|
||||||
|
methodId: null,
|
||||||
|
methodLabel: null,
|
||||||
|
amount: 0,
|
||||||
|
itemNames: input.cartItems.map((item) => item.productName),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const provinceId = await this.resolveProvinceId(input.provinceName);
|
||||||
|
const selected = input.selectedMethodsByGroupId ?? {};
|
||||||
|
|
||||||
|
if (settings.store.staticShippingEnabled) {
|
||||||
|
const amount = this.resolveProvinceAmount(
|
||||||
|
settings.store.staticShippingAmount,
|
||||||
|
settings.store.staticShippingProvinceExceptions,
|
||||||
|
provinceId,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
total: amount,
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
groupId: 'static',
|
||||||
|
title: STORE_SHIPPING_TITLE,
|
||||||
|
methodId: null,
|
||||||
|
methodLabel: null,
|
||||||
|
amount,
|
||||||
|
itemNames: input.cartItems.map((item) => item.productName),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const groups = await this.prisma.shippingCostGroup.findMany({
|
||||||
|
where: { businessId: input.businessId, isActive: true },
|
||||||
|
orderBy: [{ isDefault: 'desc' }, { sortOrder: 'asc' }, { createdAt: 'desc' }],
|
||||||
|
include: this.groupInclude(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const serialized = groups.map((item) => this.serialize(item));
|
||||||
|
const defaultGroup = serialized.find((group) => group.isDefault) ?? null;
|
||||||
|
const buckets = new Map<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
groupId: string;
|
||||||
|
title: string;
|
||||||
|
methods: ShippingMethodId[];
|
||||||
|
amount: number;
|
||||||
|
itemNames: string[];
|
||||||
|
isDefault: boolean;
|
||||||
|
}
|
||||||
|
>();
|
||||||
|
const ungroupedNames: string[] = [];
|
||||||
|
|
||||||
|
for (const item of input.cartItems) {
|
||||||
|
const matched = this.findGroupForCategoryIds(serialized, item.categoryIds);
|
||||||
|
const group = matched ?? defaultGroup;
|
||||||
|
if (!group) {
|
||||||
|
ungroupedNames.push(item.productName);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const existing = buckets.get(group.id);
|
||||||
|
if (existing) {
|
||||||
|
existing.itemNames.push(item.productName);
|
||||||
|
} else {
|
||||||
|
buckets.set(group.id, {
|
||||||
|
groupId: group.id,
|
||||||
|
title: group.isDefault
|
||||||
|
? STORE_SHIPPING_TITLE
|
||||||
|
: group.nameFa || group.nameEn,
|
||||||
|
methods: group.methods,
|
||||||
|
amount: this.resolveProvinceAmount(
|
||||||
|
group.defaultAmount,
|
||||||
|
group.provinceExceptions,
|
||||||
|
provinceId,
|
||||||
|
),
|
||||||
|
itemNames: [item.productName],
|
||||||
|
isDefault: group.isDefault,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ungroupedNames.length > 0) {
|
||||||
|
buckets.set('ungrouped', {
|
||||||
|
groupId: 'ungrouped',
|
||||||
|
title: STORE_SHIPPING_TITLE,
|
||||||
|
methods: [],
|
||||||
|
amount: 0,
|
||||||
|
itemNames: ungroupedNames,
|
||||||
|
isDefault: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const resultGroups = [...buckets.values()].map((bucket) => {
|
||||||
|
const requested = selected[bucket.groupId];
|
||||||
|
const methodId =
|
||||||
|
requested && bucket.methods.includes(requested as ShippingMethodId)
|
||||||
|
? (requested as ShippingMethodId)
|
||||||
|
: bucket.methods[0] ?? null;
|
||||||
|
return {
|
||||||
|
groupId: bucket.groupId,
|
||||||
|
title: bucket.title,
|
||||||
|
methodId,
|
||||||
|
methodLabel: methodId ? METHOD_LABELS_FA[methodId] : null,
|
||||||
|
amount: bucket.amount,
|
||||||
|
itemNames: bucket.itemNames,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
total: resultGroups.reduce((sum, group) => sum + group.amount, 0),
|
||||||
|
groups: resultGroups,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolveProvinceId(provinceName: string | null) {
|
||||||
|
const needle = provinceName?.trim();
|
||||||
|
if (!needle) return null;
|
||||||
|
const match = await this.prisma.city.findFirst({
|
||||||
|
where: {
|
||||||
|
level: 'province',
|
||||||
|
isActive: true,
|
||||||
|
OR: [
|
||||||
|
{ nameFa: needle },
|
||||||
|
{ nameEn: needle },
|
||||||
|
{ slug: needle },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
return match?.id.toString() ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveProvinceAmount(
|
||||||
|
defaultAmount: number,
|
||||||
|
exceptions: Array<{ provinceId: string; amount: number }> | undefined,
|
||||||
|
provinceId: string | null,
|
||||||
|
) {
|
||||||
|
if (!provinceId || !exceptions?.length) return defaultAmount;
|
||||||
|
const match = exceptions.find((item) => item.provinceId === provinceId);
|
||||||
|
return match ? match.amount : defaultAmount;
|
||||||
|
}
|
||||||
|
|
||||||
|
private findGroupForCategoryIds(
|
||||||
|
groups: Array<{
|
||||||
|
id: string;
|
||||||
|
isDefault: boolean;
|
||||||
|
categoryIds: string[];
|
||||||
|
nameFa: string;
|
||||||
|
nameEn: string;
|
||||||
|
methods: ShippingMethodId[];
|
||||||
|
defaultAmount: number;
|
||||||
|
provinceExceptions?: Array<{ provinceId: string; amount: number }>;
|
||||||
|
}>,
|
||||||
|
categoryIds: string[],
|
||||||
|
) {
|
||||||
|
const categorySet = new Set(categoryIds);
|
||||||
|
const byCategory = groups.filter((group) =>
|
||||||
|
group.categoryIds.some((id) => categorySet.has(id)),
|
||||||
|
);
|
||||||
|
if (byCategory.length === 0) return null;
|
||||||
|
return byCategory.find((group) => !group.isDefault) ?? byCategory[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private groupInclude() {
|
||||||
|
return {
|
||||||
|
categories: {
|
||||||
|
include: {
|
||||||
|
category: {
|
||||||
|
select: { id: true, name: true, nameFa: true, slug: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
cityRates: true,
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
private serialize(group: GroupWithRelations) {
|
||||||
|
return {
|
||||||
|
id: group.id.toString(),
|
||||||
|
businessId: group.businessId.toString(),
|
||||||
|
nameFa: group.nameFa,
|
||||||
|
nameEn: group.nameEn,
|
||||||
|
description: group.description,
|
||||||
|
methods: group.methods.filter((method): method is ShippingMethodId =>
|
||||||
|
METHOD_SET.has(method),
|
||||||
|
),
|
||||||
|
defaultAmount: Number(group.defaultAmount),
|
||||||
|
sortOrder: group.sortOrder,
|
||||||
|
isActive: group.isActive,
|
||||||
|
isDefault: group.isDefault,
|
||||||
|
categoryIds: group.categories.map((row) => row.categoryId.toString()),
|
||||||
|
categories: group.categories.map((row) => ({
|
||||||
|
id: row.category.id.toString(),
|
||||||
|
name: row.category.name,
|
||||||
|
nameFa: row.category.nameFa,
|
||||||
|
slug: row.category.slug,
|
||||||
|
})),
|
||||||
|
cityRates: group.cityRates.map((rate) => ({
|
||||||
|
id: rate.id.toString(),
|
||||||
|
cityId: rate.cityId.toString(),
|
||||||
|
amount: Number(rate.amount),
|
||||||
|
})),
|
||||||
|
provinceExceptions: group.cityRates.map((rate) => ({
|
||||||
|
id: rate.id.toString(),
|
||||||
|
provinceId: rate.cityId.toString(),
|
||||||
|
amount: Number(rate.amount),
|
||||||
|
})),
|
||||||
|
createdAt: group.createdAt.toISOString(),
|
||||||
|
updatedAt: group.updatedAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeMethods(methods: ShippingMethodId[]) {
|
||||||
|
const unique = [...new Set(methods)];
|
||||||
|
if (!unique.length) {
|
||||||
|
throw new BadRequestException('Select at least one shipping method');
|
||||||
|
}
|
||||||
|
for (const method of unique) {
|
||||||
|
if (!METHOD_SET.has(method)) {
|
||||||
|
throw new BadRequestException(`Invalid shipping method: ${method}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return unique;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolveProvinceIds(
|
||||||
|
exceptions: Array<{ provinceId: string; amount: number }>,
|
||||||
|
) {
|
||||||
|
if (!exceptions.length) return [] as Array<{ provinceId: bigint; amount: number }>;
|
||||||
|
|
||||||
|
const unique = new Map<string, number>();
|
||||||
|
for (const entry of exceptions) {
|
||||||
|
const provinceId = entry.provinceId.trim();
|
||||||
|
if (!provinceId) continue;
|
||||||
|
if (!Number.isFinite(entry.amount) || entry.amount < 0) {
|
||||||
|
throw new BadRequestException('Invalid province exception amount');
|
||||||
|
}
|
||||||
|
unique.set(provinceId, entry.amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ids = [...unique.keys()].map((id) => {
|
||||||
|
try {
|
||||||
|
return BigInt(id);
|
||||||
|
} catch {
|
||||||
|
throw new BadRequestException(`Invalid province id: ${id}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const found = await this.prisma.city.findMany({
|
||||||
|
where: {
|
||||||
|
id: { in: ids },
|
||||||
|
level: 'province',
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (found.length !== ids.length) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'One or more provinces were not found',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...unique.entries()].map(([provinceId, amount]) => ({
|
||||||
|
provinceId: BigInt(provinceId),
|
||||||
|
amount,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolveCategoryIds(businessId: bigint, rawIds: string[]) {
|
||||||
|
if (!rawIds.length) return [] as bigint[];
|
||||||
|
|
||||||
|
const unique = [...new Set(rawIds.map((id) => id.trim()).filter(Boolean))];
|
||||||
|
const ids = unique.map((id) => {
|
||||||
|
try {
|
||||||
|
return BigInt(id);
|
||||||
|
} catch {
|
||||||
|
throw new BadRequestException(`Invalid category id: ${id}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const found = await this.prisma.category.findMany({
|
||||||
|
where: {
|
||||||
|
businessId,
|
||||||
|
entityType: 'product',
|
||||||
|
id: { in: ids },
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (found.length !== ids.length) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'One or more categories were not found for this business',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async findOrThrow(businessId: bigint, groupId: bigint) {
|
||||||
|
const group = await this.prisma.shippingCostGroup.findFirst({
|
||||||
|
where: { id: groupId, businessId },
|
||||||
|
include: this.groupInclude(),
|
||||||
|
});
|
||||||
|
if (!group) {
|
||||||
|
throw new NotFoundException('Shipping cost group not found');
|
||||||
|
}
|
||||||
|
return group;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertPermission(
|
||||||
|
businessId: bigint,
|
||||||
|
userId: bigint,
|
||||||
|
permission: string,
|
||||||
|
) {
|
||||||
|
const allowed = await this.permissions.hasBusinessPermission(
|
||||||
|
userId,
|
||||||
|
businessId,
|
||||||
|
permission,
|
||||||
|
);
|
||||||
|
if (!allowed) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
`Missing permission: ${permission} for this business`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertStoreModuleEnabled(businessId: bigint) {
|
||||||
|
const business = await this.prisma.business.findUnique({
|
||||||
|
where: { id: businessId },
|
||||||
|
select: { settings: true },
|
||||||
|
});
|
||||||
|
if (!business) {
|
||||||
|
throw new NotFoundException('Business not found');
|
||||||
|
}
|
||||||
|
const settings = normalizeBusinessSettings(business.settings);
|
||||||
|
if (!settings.modules.enabled.includes('store')) {
|
||||||
|
throw new ForbiddenException('Store module is not enabled');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,11 @@ import {
|
|||||||
StoreLoanMethodsController,
|
StoreLoanMethodsController,
|
||||||
} from './store-loan-methods.controller';
|
} from './store-loan-methods.controller';
|
||||||
import { StoreLoanMethodsService } from './store-loan-methods.service';
|
import { StoreLoanMethodsService } from './store-loan-methods.service';
|
||||||
|
import {
|
||||||
|
PublicShippingCostGroupsController,
|
||||||
|
ShippingCostGroupsController,
|
||||||
|
} from './shipping-cost-groups.controller';
|
||||||
|
import { ShippingCostGroupsService } from './shipping-cost-groups.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [AuthModule, TenantModule, WebsiteAnalyticsModule],
|
imports: [AuthModule, TenantModule, WebsiteAnalyticsModule],
|
||||||
@@ -25,13 +30,21 @@ import { StoreLoanMethodsService } from './store-loan-methods.service';
|
|||||||
StoreSpecialsController,
|
StoreSpecialsController,
|
||||||
PublicStoreLoanMethodsController,
|
PublicStoreLoanMethodsController,
|
||||||
StoreLoanMethodsController,
|
StoreLoanMethodsController,
|
||||||
|
PublicShippingCostGroupsController,
|
||||||
|
ShippingCostGroupsController,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
StoreItemsService,
|
StoreItemsService,
|
||||||
StoreSpecialsService,
|
StoreSpecialsService,
|
||||||
StoreSpecialsAiService,
|
StoreSpecialsAiService,
|
||||||
StoreLoanMethodsService,
|
StoreLoanMethodsService,
|
||||||
|
ShippingCostGroupsService,
|
||||||
|
],
|
||||||
|
exports: [
|
||||||
|
StoreItemsService,
|
||||||
|
StoreSpecialsService,
|
||||||
|
StoreLoanMethodsService,
|
||||||
|
ShippingCostGroupsService,
|
||||||
],
|
],
|
||||||
exports: [StoreItemsService, StoreSpecialsService, StoreLoanMethodsService],
|
|
||||||
})
|
})
|
||||||
export class StoreModule {}
|
export class StoreModule {}
|
||||||
|
|||||||
Reference in New Issue
Block a user