Add store loan methods with public website calculate API.
Supports dashboard CRUD and tenant list/get/calculate so storefronts can offer installments, requiring a method pick when several exist. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
2a01873c95
commit
4a9e10e614
@@ -0,0 +1,37 @@
|
|||||||
|
-- Store loan / installment methods (اقساط و تسهیلات)
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS store_loan_methods (
|
||||||
|
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,
|
||||||
|
image_media_id BIGINT NULL REFERENCES media (id) ON UPDATE NO ACTION,
|
||||||
|
image_aspect_ratio VARCHAR(10) NOT NULL DEFAULT '1:1',
|
||||||
|
min_shopping_amount NUMERIC(12, 2) NOT NULL,
|
||||||
|
min_amount NUMERIC(12, 2) NOT NULL,
|
||||||
|
max_amount NUMERIC(12, 2) NOT NULL,
|
||||||
|
interest NUMERIC(8, 4) NOT NULL,
|
||||||
|
return_months_min INT NOT NULL,
|
||||||
|
return_months_max INT NOT NULL,
|
||||||
|
return_months_step INT NOT NULL DEFAULT 3,
|
||||||
|
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_store_loan_methods_business_id
|
||||||
|
ON store_loan_methods (business_id);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_store_loan_methods_image_media_id
|
||||||
|
ON store_loan_methods (image_media_id);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_store_loan_methods_business_sort_order
|
||||||
|
ON store_loan_methods (business_id, sort_order);
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS store_loan_methods_set_updated_at ON store_loan_methods;
|
||||||
|
CREATE TRIGGER store_loan_methods_set_updated_at
|
||||||
|
BEFORE UPDATE ON store_loan_methods
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION set_updated_at();
|
||||||
@@ -214,6 +214,8 @@ StoreItem 1──* StoreItemVariant (purchasable SKUs: price, stock, variation c
|
|||||||
StoreItemVariant 1──* StoreItemVariantSelection → CategoryVariationOption
|
StoreItemVariant 1──* StoreItemVariantSelection → CategoryVariationOption
|
||||||
Product 1──* ProductTechnicalFieldValue → CategoryTechnicalFormField
|
Product 1──* ProductTechnicalFieldValue → CategoryTechnicalFormField
|
||||||
|
|
||||||
|
Business 1──* StoreLoanMethod (installments / facilities; module `store_installments`)
|
||||||
|
|
||||||
UserProduct *── Category (via CategoryAssignment, entityType user_product → product categories)
|
UserProduct *── Category (via CategoryAssignment, entityType user_product → product categories)
|
||||||
UserProduct 1──* UserProductTechnicalFieldValue → CategoryTechnicalFormField
|
UserProduct 1──* UserProductTechnicalFieldValue → CategoryTechnicalFormField
|
||||||
UserProduct → City (country, city, optional district)
|
UserProduct → City (country, city, optional district)
|
||||||
@@ -284,6 +286,9 @@ All routes are prefixed with `/api/v1`.
|
|||||||
| GET | `/tenants/:host/portfolios/by-id/:portfolioId` | Public portfolio by id |
|
| GET | `/tenants/:host/portfolios/by-id/:portfolioId` | Public portfolio by id |
|
||||||
| POST | `/businesses/:id/website/sitemap/sync` | Import manifest from `GET https://{domain}/meshkee/sitemap-config.json` |
|
| POST | `/businesses/:id/website/sitemap/sync` | Import manifest from `GET https://{domain}/meshkee/sitemap-config.json` |
|
||||||
| GET | `/tenants/:host/store-specials` | Active store specials (`source` is `product` or `store_item`) |
|
| GET | `/tenants/:host/store-specials` | Active store specials (`source` is `product` or `store_item`) |
|
||||||
|
| GET | `/tenants/:host/store-loan-methods` | Active installment/credit methods (`enabled` + `items[]`; needs `store` + `store_installments`) |
|
||||||
|
| GET | `/tenants/:host/store-loan-methods/:methodId` | One active method |
|
||||||
|
| POST | `/tenants/:host/store-loan-methods/calculate` | Installment plan — body requires `methodId` (pick method first if multiple) |
|
||||||
| GET | `/tenants/:host/website/category-groups` | Homepage category rows |
|
| GET | `/tenants/:host/website/category-groups` | Homepage category rows |
|
||||||
| GET | `/tenants/:host/website/brand-groups` | Homepage brand rows |
|
| GET | `/tenants/:host/website/brand-groups` | Homepage brand rows |
|
||||||
| GET | `/tenants/:host/website/sliders` | Homepage sliders with slides |
|
| GET | `/tenants/:host/website/sliders` | Homepage sliders with slides |
|
||||||
@@ -518,6 +523,42 @@ Stored in `businesses.settings.website.specialProductsSource` (`product` | `stor
|
|||||||
- Dashboard: `PATCH /businesses/:id/settings` with `{ website: { specialProductsSource } }`.
|
- Dashboard: `PATCH /businesses/:id/settings` with `{ website: { specialProductsSource } }`.
|
||||||
- Public: `GET /tenants/:host` includes `specialProductsSource`; `GET /tenants/:host/store-specials` includes top-level `source`.
|
- Public: `GET /tenants/:host` includes `specialProductsSource`; `GET /tenants/:host/store-specials` includes top-level `source`.
|
||||||
|
|
||||||
|
### Store loan methods (installments & facilities)
|
||||||
|
|
||||||
|
Requires modules `store` + `store_installments` (opt-in). Dashboard: Store → اقساط و تسهیلات.
|
||||||
|
|
||||||
|
| Field | Notes |
|
||||||
|
|-------|-------|
|
||||||
|
| `nameFa` / `nameEn` | Display names |
|
||||||
|
| `minShoppingAmount` | Minimum purchase total to use this method |
|
||||||
|
| `minAmount` / `maxAmount` | Allowed credit range |
|
||||||
|
| `interest` | Flat annual %; fee = credit × (interest/100) × (months/12) |
|
||||||
|
| `returnMonthsMin` / `Max` / `Step` | Term options (step ∈ 2,3,4,6,12) |
|
||||||
|
| `imageMediaId` / `imageAspectRatio` | Optional image (`1:1` or `3:2`) |
|
||||||
|
|
||||||
|
**Dashboard (auth, `products.read` / `products.update`):**
|
||||||
|
- `GET/POST /businesses/:businessId/store-loan-methods`
|
||||||
|
- `GET/PATCH/DELETE /businesses/:businessId/store-loan-methods/:methodId`
|
||||||
|
|
||||||
|
**Public website:**
|
||||||
|
- `GET /tenants/:host/store-loan-methods` → `{ enabled, items[] }` (`enabled: false` + empty when module off)
|
||||||
|
- `GET /tenants/:host/store-loan-methods/:methodId` → `{ method }` (active only)
|
||||||
|
- `POST /tenants/:host/store-loan-methods/calculate` → `{ method, plan }` — **always requires `methodId`**
|
||||||
|
|
||||||
|
Calculator UX when a site has multiple methods: list first → if `items.length > 1`, ask the shopper which method → then collect purchase total / credit / months → `POST .../calculate`. If exactly one method, auto-select it. Never calculate without a chosen `methodId`.
|
||||||
|
|
||||||
|
```json
|
||||||
|
POST /tenants/:host/store-loan-methods/calculate
|
||||||
|
{
|
||||||
|
"methodId": "1",
|
||||||
|
"totalValue": 5000000,
|
||||||
|
"creditAmount": 2000000,
|
||||||
|
"months": 12
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`plan` includes `cashPayment`, `interestFee`, `totalRepay`, and monthly `installments[]` (`index`, `dueDate`, `amount`). Public items also include `returnMonthOptions[]`.
|
||||||
|
|
||||||
**Example — batch create variants for a product:**
|
**Example — batch create variants for a product:**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
@@ -667,6 +708,7 @@ See `.env.example` for the full list. Key groups:
|
|||||||
- Products CRUD
|
- Products CRUD
|
||||||
- Product variation values (which options a product offers)
|
- Product variation values (which options a product offers)
|
||||||
- Store items / product variants (price, stock, SKU)
|
- Store items / product variants (price, stock, SKU)
|
||||||
|
- Store loan methods / installments (`store_installments`): dashboard CRUD + public list/get/calculate
|
||||||
- Torob Product API v3 (`POST /tenants/:host/torob_api/v3/products`) for store-module websites; nginx proxies `/torob_api/v3/products` on the shop apex
|
- Torob Product API v3 (`POST /tenants/:host/torob_api/v3/products`) for store-module websites; nginx proxies `/torob_api/v3/products` on the shop apex
|
||||||
- Shopping cart + checkout + orders (customer + admin)
|
- Shopping cart + checkout + orders (customer + admin)
|
||||||
- Online e-payment (Mellat + ZarinPal; stubs for SEP / Snapp Pay / DigiPay)
|
- Online e-payment (Mellat + ZarinPal; stubs for SEP / Snapp Pay / DigiPay)
|
||||||
|
|||||||
@@ -160,6 +160,7 @@ model Business {
|
|||||||
shoppingCards ShoppingCard[]
|
shoppingCards ShoppingCard[]
|
||||||
storeItemVariants StoreItemVariant[]
|
storeItemVariants StoreItemVariant[]
|
||||||
storeItems StoreItem[]
|
storeItems StoreItem[]
|
||||||
|
storeLoanMethods StoreLoanMethod[]
|
||||||
storeSpecials StoreSpecial[]
|
storeSpecials StoreSpecial[]
|
||||||
transactions Transaction[]
|
transactions Transaction[]
|
||||||
userProductTechnicalFieldValues UserProductTechnicalFieldValue[]
|
userProductTechnicalFieldValues UserProductTechnicalFieldValue[]
|
||||||
@@ -379,6 +380,7 @@ model Media {
|
|||||||
oldId BigInt? @map("old_id")
|
oldId BigInt? @map("old_id")
|
||||||
blogs blogs[]
|
blogs blogs[]
|
||||||
brandImages Brand[]
|
brandImages Brand[]
|
||||||
|
storeLoanMethodImages StoreLoanMethod[]
|
||||||
faviconBusinesses Business[] @relation("BusinessFavicon")
|
faviconBusinesses Business[] @relation("BusinessFavicon")
|
||||||
logoBusinesses Business[] @relation("BusinessLogo")
|
logoBusinesses Business[] @relation("BusinessLogo")
|
||||||
logoDarkBusinesses Business[] @relation("BusinessLogoDark")
|
logoDarkBusinesses Business[] @relation("BusinessLogoDark")
|
||||||
@@ -1220,6 +1222,34 @@ model StoreSpecial {
|
|||||||
@@map("store_specials")
|
@@map("store_specials")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model StoreLoanMethod {
|
||||||
|
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?
|
||||||
|
imageMediaId BigInt? @map("image_media_id")
|
||||||
|
imageAspectRatio String @default("1:1") @map("image_aspect_ratio") @db.VarChar(10)
|
||||||
|
minShoppingAmount Decimal @map("min_shopping_amount") @db.Decimal(12, 2)
|
||||||
|
minAmount Decimal @map("min_amount") @db.Decimal(12, 2)
|
||||||
|
maxAmount Decimal @map("max_amount") @db.Decimal(12, 2)
|
||||||
|
interest Decimal @db.Decimal(8, 4)
|
||||||
|
returnMonthsMin Int @map("return_months_min")
|
||||||
|
returnMonthsMax Int @map("return_months_max")
|
||||||
|
returnMonthsStep Int @default(3) @map("return_months_step")
|
||||||
|
sortOrder Int @default(0) @map("sort_order")
|
||||||
|
isActive Boolean @default(true) @map("is_active")
|
||||||
|
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)
|
||||||
|
imageMedia Media? @relation(fields: [imageMediaId], references: [id], onUpdate: NoAction)
|
||||||
|
|
||||||
|
@@index([businessId], map: "idx_store_loan_methods_business_id")
|
||||||
|
@@index([imageMediaId], map: "idx_store_loan_methods_image_media_id")
|
||||||
|
@@index([businessId, sortOrder], map: "idx_store_loan_methods_business_sort_order")
|
||||||
|
@@map("store_loan_methods")
|
||||||
|
}
|
||||||
|
|
||||||
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
|
||||||
|
|||||||
@@ -188,6 +188,7 @@ export type WebsiteSitemapConfig = {
|
|||||||
export const BUSINESS_DASHBOARD_MODULE_IDS = [
|
export const BUSINESS_DASHBOARD_MODULE_IDS = [
|
||||||
'products',
|
'products',
|
||||||
'store',
|
'store',
|
||||||
|
'store_installments',
|
||||||
'portfolio',
|
'portfolio',
|
||||||
'blog',
|
'blog',
|
||||||
'warehouse',
|
'warehouse',
|
||||||
@@ -300,7 +301,10 @@ export const DEFAULT_NEW_BUSINESS_MODULES: BusinessModuleId[] = [
|
|||||||
*/
|
*/
|
||||||
export const DEFAULT_ENABLED_BUSINESS_MODULES: BusinessModuleId[] =
|
export const DEFAULT_ENABLED_BUSINESS_MODULES: BusinessModuleId[] =
|
||||||
BUSINESS_DASHBOARD_MODULE_IDS.filter(
|
BUSINESS_DASHBOARD_MODULE_IDS.filter(
|
||||||
(id) => id !== 'workshops' && id !== 'multilanguage_data',
|
(id) =>
|
||||||
|
id !== 'workshops' &&
|
||||||
|
id !== 'multilanguage_data' &&
|
||||||
|
id !== 'store_installments',
|
||||||
);
|
);
|
||||||
|
|
||||||
export const DEFAULT_HOME_CHARTS: [HomeChartId, HomeChartId] = [
|
export const DEFAULT_HOME_CHARTS: [HomeChartId, HomeChartId] = [
|
||||||
|
|||||||
@@ -0,0 +1,206 @@
|
|||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import {
|
||||||
|
IsBoolean,
|
||||||
|
IsIn,
|
||||||
|
IsInt,
|
||||||
|
IsNumber,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
MaxLength,
|
||||||
|
Min,
|
||||||
|
MinLength,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
export const STORE_LOAN_IMAGE_ASPECTS = ['1:1', '3:2'] as const;
|
||||||
|
export type StoreLoanImageAspect = (typeof STORE_LOAN_IMAGE_ASPECTS)[number];
|
||||||
|
|
||||||
|
export const STORE_LOAN_MONTH_STEPS = [2, 3, 4, 6, 12] as const;
|
||||||
|
export type StoreLoanMonthStep = (typeof STORE_LOAN_MONTH_STEPS)[number];
|
||||||
|
|
||||||
|
export class ListStoreLoanMethodsDto {
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
page?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
pageSize?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Boolean)
|
||||||
|
@IsBoolean()
|
||||||
|
isActive?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CreateStoreLoanMethodDto {
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
@MaxLength(255)
|
||||||
|
nameFa!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
@MaxLength(255)
|
||||||
|
nameEn!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
imageMediaId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(STORE_LOAN_IMAGE_ASPECTS)
|
||||||
|
imageAspectRatio?: StoreLoanImageAspect;
|
||||||
|
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsNumber({ maxDecimalPlaces: 2 })
|
||||||
|
@Min(0)
|
||||||
|
minShoppingAmount!: number;
|
||||||
|
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsNumber({ maxDecimalPlaces: 2 })
|
||||||
|
@Min(0)
|
||||||
|
minAmount!: number;
|
||||||
|
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsNumber({ maxDecimalPlaces: 2 })
|
||||||
|
@Min(0)
|
||||||
|
maxAmount!: number;
|
||||||
|
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsNumber({ maxDecimalPlaces: 4 })
|
||||||
|
@Min(0)
|
||||||
|
interest!: number;
|
||||||
|
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
returnMonthsMin!: number;
|
||||||
|
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
returnMonthsMax!: number;
|
||||||
|
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@IsIn([...STORE_LOAN_MONTH_STEPS])
|
||||||
|
returnMonthsStep!: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
sortOrder?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
isActive?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdateStoreLoanMethodDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
@MaxLength(255)
|
||||||
|
nameFa?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
@MaxLength(255)
|
||||||
|
nameEn?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
description?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
imageMediaId?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(STORE_LOAN_IMAGE_ASPECTS)
|
||||||
|
imageAspectRatio?: StoreLoanImageAspect;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsNumber({ maxDecimalPlaces: 2 })
|
||||||
|
@Min(0)
|
||||||
|
minShoppingAmount?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsNumber({ maxDecimalPlaces: 2 })
|
||||||
|
@Min(0)
|
||||||
|
minAmount?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsNumber({ maxDecimalPlaces: 2 })
|
||||||
|
@Min(0)
|
||||||
|
maxAmount?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsNumber({ maxDecimalPlaces: 4 })
|
||||||
|
@Min(0)
|
||||||
|
interest?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
returnMonthsMin?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
returnMonthsMax?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@IsIn([...STORE_LOAN_MONTH_STEPS])
|
||||||
|
returnMonthsStep?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
sortOrder?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
isActive?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Public website calculator — methodId is required (pick method first when multiple exist). */
|
||||||
|
export class CalculateStoreLoanMethodDto {
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
methodId!: string;
|
||||||
|
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsNumber({ maxDecimalPlaces: 2 })
|
||||||
|
@Min(0)
|
||||||
|
totalValue!: number;
|
||||||
|
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsNumber({ maxDecimalPlaces: 2 })
|
||||||
|
@Min(0)
|
||||||
|
creditAmount!: number;
|
||||||
|
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
months!: number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
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 {
|
||||||
|
CalculateStoreLoanMethodDto,
|
||||||
|
CreateStoreLoanMethodDto,
|
||||||
|
ListStoreLoanMethodsDto,
|
||||||
|
UpdateStoreLoanMethodDto,
|
||||||
|
} from './dto/store-loan-methods.dto';
|
||||||
|
import { StoreLoanMethodsService } from './store-loan-methods.service';
|
||||||
|
|
||||||
|
@Controller('tenants/:host/store-loan-methods')
|
||||||
|
export class PublicStoreLoanMethodsController {
|
||||||
|
constructor(private readonly service: StoreLoanMethodsService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
list(@Param('host') host: string) {
|
||||||
|
return this.service.listPublic(host);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('calculate')
|
||||||
|
calculate(
|
||||||
|
@Param('host') host: string,
|
||||||
|
@Body() dto: CalculateStoreLoanMethodDto,
|
||||||
|
) {
|
||||||
|
return this.service.calculatePublic(host, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':methodId')
|
||||||
|
getOne(@Param('host') host: string, @Param('methodId') methodId: string) {
|
||||||
|
return this.service.getOnePublic(host, methodId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Controller('businesses/:businessId/store-loan-methods')
|
||||||
|
@UseGuards(JwtAuthGuard, BusinessPermissionGuard)
|
||||||
|
export class StoreLoanMethodsController {
|
||||||
|
constructor(private readonly service: StoreLoanMethodsService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@RequireBusinessPermission('products.read')
|
||||||
|
list(
|
||||||
|
@Param('businessId') businessId: string,
|
||||||
|
@Query() query: ListStoreLoanMethodsDto,
|
||||||
|
@CurrentUser() user: AuthUser,
|
||||||
|
) {
|
||||||
|
return this.service.list(businessId, query, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':methodId')
|
||||||
|
@RequireBusinessPermission('products.read')
|
||||||
|
getOne(
|
||||||
|
@Param('businessId') businessId: string,
|
||||||
|
@Param('methodId') methodId: string,
|
||||||
|
@CurrentUser() user: AuthUser,
|
||||||
|
) {
|
||||||
|
return this.service.getOne(businessId, methodId, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@RequireBusinessPermission('products.update')
|
||||||
|
create(
|
||||||
|
@Param('businessId') businessId: string,
|
||||||
|
@Body() dto: CreateStoreLoanMethodDto,
|
||||||
|
@CurrentUser() user: AuthUser,
|
||||||
|
) {
|
||||||
|
return this.service.create(businessId, dto, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':methodId')
|
||||||
|
@RequireBusinessPermission('products.update')
|
||||||
|
update(
|
||||||
|
@Param('businessId') businessId: string,
|
||||||
|
@Param('methodId') methodId: string,
|
||||||
|
@Body() dto: UpdateStoreLoanMethodDto,
|
||||||
|
@CurrentUser() user: AuthUser,
|
||||||
|
) {
|
||||||
|
return this.service.update(businessId, methodId, dto, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':methodId')
|
||||||
|
@RequireBusinessPermission('products.update')
|
||||||
|
remove(
|
||||||
|
@Param('businessId') businessId: string,
|
||||||
|
@Param('methodId') methodId: string,
|
||||||
|
@CurrentUser() user: AuthUser,
|
||||||
|
) {
|
||||||
|
return this.service.remove(businessId, methodId, user);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,571 @@
|
|||||||
|
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 {
|
||||||
|
CalculateStoreLoanMethodDto,
|
||||||
|
CreateStoreLoanMethodDto,
|
||||||
|
ListStoreLoanMethodsDto,
|
||||||
|
STORE_LOAN_IMAGE_ASPECTS,
|
||||||
|
STORE_LOAN_MONTH_STEPS,
|
||||||
|
UpdateStoreLoanMethodDto,
|
||||||
|
} from './dto/store-loan-methods.dto';
|
||||||
|
|
||||||
|
type LoanMethodWithImage = Prisma.StoreLoanMethodGetPayload<{
|
||||||
|
include: { imageMedia: true };
|
||||||
|
}>;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class StoreLoanMethodsService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly permissions: PermissionsService,
|
||||||
|
private readonly tenant: TenantService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async list(
|
||||||
|
businessIdRaw: string,
|
||||||
|
query: ListStoreLoanMethodsDto,
|
||||||
|
actor: AuthUser,
|
||||||
|
) {
|
||||||
|
const businessId = BigInt(businessIdRaw);
|
||||||
|
await this.assertPermission(businessId, actor.id, 'products.read');
|
||||||
|
await this.assertModuleEnabled(businessId);
|
||||||
|
|
||||||
|
const page = query.page ?? 1;
|
||||||
|
const pageSize = query.pageSize ?? 20;
|
||||||
|
const skip = (page - 1) * pageSize;
|
||||||
|
|
||||||
|
const where: Prisma.StoreLoanMethodWhereInput = {
|
||||||
|
businessId,
|
||||||
|
...(query.isActive !== undefined ? { isActive: query.isActive } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const [items, total] = await Promise.all([
|
||||||
|
this.prisma.storeLoanMethod.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'desc' }],
|
||||||
|
skip,
|
||||||
|
take: pageSize,
|
||||||
|
include: { imageMedia: true },
|
||||||
|
}),
|
||||||
|
this.prisma.storeLoanMethod.count({ where }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: items.map((item) => this.serialize(item)),
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getOne(
|
||||||
|
businessIdRaw: string,
|
||||||
|
methodIdRaw: string,
|
||||||
|
actor: AuthUser,
|
||||||
|
) {
|
||||||
|
const businessId = BigInt(businessIdRaw);
|
||||||
|
const methodId = BigInt(methodIdRaw);
|
||||||
|
await this.assertPermission(businessId, actor.id, 'products.read');
|
||||||
|
await this.assertModuleEnabled(businessId);
|
||||||
|
|
||||||
|
const method = await this.findOrThrow(businessId, methodId);
|
||||||
|
return { method: this.serialize(method) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(
|
||||||
|
businessIdRaw: string,
|
||||||
|
dto: CreateStoreLoanMethodDto,
|
||||||
|
actor: AuthUser,
|
||||||
|
) {
|
||||||
|
const businessId = BigInt(businessIdRaw);
|
||||||
|
await this.assertPermission(businessId, actor.id, 'products.update');
|
||||||
|
await this.assertModuleEnabled(businessId);
|
||||||
|
this.assertAmountRanges(dto);
|
||||||
|
this.assertReturnMonths(
|
||||||
|
dto.returnMonthsMin,
|
||||||
|
dto.returnMonthsMax,
|
||||||
|
dto.returnMonthsStep,
|
||||||
|
);
|
||||||
|
|
||||||
|
let imageMediaId: bigint | null = null;
|
||||||
|
if (dto.imageMediaId) {
|
||||||
|
imageMediaId = BigInt(dto.imageMediaId);
|
||||||
|
await this.assertImageMedia(businessId, imageMediaId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const created = await this.prisma.storeLoanMethod.create({
|
||||||
|
data: {
|
||||||
|
businessId,
|
||||||
|
nameFa: dto.nameFa.trim(),
|
||||||
|
nameEn: dto.nameEn.trim(),
|
||||||
|
description: dto.description?.trim() || null,
|
||||||
|
imageMediaId,
|
||||||
|
imageAspectRatio: dto.imageAspectRatio ?? '1:1',
|
||||||
|
minShoppingAmount: dto.minShoppingAmount,
|
||||||
|
minAmount: dto.minAmount,
|
||||||
|
maxAmount: dto.maxAmount,
|
||||||
|
interest: dto.interest,
|
||||||
|
returnMonthsMin: dto.returnMonthsMin,
|
||||||
|
returnMonthsMax: dto.returnMonthsMax,
|
||||||
|
returnMonthsStep: dto.returnMonthsStep,
|
||||||
|
sortOrder: dto.sortOrder ?? 0,
|
||||||
|
isActive: dto.isActive ?? true,
|
||||||
|
},
|
||||||
|
include: { imageMedia: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
message: 'Loan method created successfully',
|
||||||
|
method: this.serialize(created),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(
|
||||||
|
businessIdRaw: string,
|
||||||
|
methodIdRaw: string,
|
||||||
|
dto: UpdateStoreLoanMethodDto,
|
||||||
|
actor: AuthUser,
|
||||||
|
) {
|
||||||
|
const businessId = BigInt(businessIdRaw);
|
||||||
|
const methodId = BigInt(methodIdRaw);
|
||||||
|
await this.assertPermission(businessId, actor.id, 'products.update');
|
||||||
|
await this.assertModuleEnabled(businessId);
|
||||||
|
|
||||||
|
const existing = await this.findOrThrow(businessId, methodId);
|
||||||
|
|
||||||
|
const minShoppingAmount =
|
||||||
|
dto.minShoppingAmount !== undefined
|
||||||
|
? dto.minShoppingAmount
|
||||||
|
: Number(existing.minShoppingAmount);
|
||||||
|
const minAmount =
|
||||||
|
dto.minAmount !== undefined ? dto.minAmount : Number(existing.minAmount);
|
||||||
|
const maxAmount =
|
||||||
|
dto.maxAmount !== undefined ? dto.maxAmount : Number(existing.maxAmount);
|
||||||
|
const returnMonthsMin =
|
||||||
|
dto.returnMonthsMin !== undefined
|
||||||
|
? dto.returnMonthsMin
|
||||||
|
: existing.returnMonthsMin;
|
||||||
|
const returnMonthsMax =
|
||||||
|
dto.returnMonthsMax !== undefined
|
||||||
|
? dto.returnMonthsMax
|
||||||
|
: existing.returnMonthsMax;
|
||||||
|
const returnMonthsStep =
|
||||||
|
dto.returnMonthsStep !== undefined
|
||||||
|
? dto.returnMonthsStep
|
||||||
|
: existing.returnMonthsStep;
|
||||||
|
|
||||||
|
this.assertAmountRanges({
|
||||||
|
minShoppingAmount,
|
||||||
|
minAmount,
|
||||||
|
maxAmount,
|
||||||
|
});
|
||||||
|
this.assertReturnMonths(returnMonthsMin, returnMonthsMax, returnMonthsStep);
|
||||||
|
|
||||||
|
let imageMediaId: bigint | null | undefined = undefined;
|
||||||
|
if (dto.imageMediaId !== undefined) {
|
||||||
|
if (dto.imageMediaId === null || dto.imageMediaId === '') {
|
||||||
|
imageMediaId = null;
|
||||||
|
} else {
|
||||||
|
imageMediaId = BigInt(dto.imageMediaId);
|
||||||
|
await this.assertImageMedia(businessId, imageMediaId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
dto.imageAspectRatio !== undefined &&
|
||||||
|
!STORE_LOAN_IMAGE_ASPECTS.includes(dto.imageAspectRatio)
|
||||||
|
) {
|
||||||
|
throw new BadRequestException('Invalid image aspect ratio');
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await this.prisma.storeLoanMethod.update({
|
||||||
|
where: { id: methodId },
|
||||||
|
data: {
|
||||||
|
...(dto.nameFa !== undefined ? { nameFa: dto.nameFa.trim() } : {}),
|
||||||
|
...(dto.nameEn !== undefined ? { nameEn: dto.nameEn.trim() } : {}),
|
||||||
|
...(dto.description !== undefined
|
||||||
|
? { description: dto.description?.trim() || null }
|
||||||
|
: {}),
|
||||||
|
...(imageMediaId !== undefined ? { imageMediaId } : {}),
|
||||||
|
...(dto.imageAspectRatio !== undefined
|
||||||
|
? { imageAspectRatio: dto.imageAspectRatio }
|
||||||
|
: {}),
|
||||||
|
...(dto.minShoppingAmount !== undefined
|
||||||
|
? { minShoppingAmount: dto.minShoppingAmount }
|
||||||
|
: {}),
|
||||||
|
...(dto.minAmount !== undefined ? { minAmount: dto.minAmount } : {}),
|
||||||
|
...(dto.maxAmount !== undefined ? { maxAmount: dto.maxAmount } : {}),
|
||||||
|
...(dto.interest !== undefined ? { interest: dto.interest } : {}),
|
||||||
|
...(dto.returnMonthsMin !== undefined
|
||||||
|
? { returnMonthsMin: dto.returnMonthsMin }
|
||||||
|
: {}),
|
||||||
|
...(dto.returnMonthsMax !== undefined
|
||||||
|
? { returnMonthsMax: dto.returnMonthsMax }
|
||||||
|
: {}),
|
||||||
|
...(dto.returnMonthsStep !== undefined
|
||||||
|
? { returnMonthsStep: dto.returnMonthsStep }
|
||||||
|
: {}),
|
||||||
|
...(dto.sortOrder !== undefined ? { sortOrder: dto.sortOrder } : {}),
|
||||||
|
...(dto.isActive !== undefined ? { isActive: dto.isActive } : {}),
|
||||||
|
},
|
||||||
|
include: { imageMedia: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
message: 'Loan method updated successfully',
|
||||||
|
method: this.serialize(updated),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(
|
||||||
|
businessIdRaw: string,
|
||||||
|
methodIdRaw: string,
|
||||||
|
actor: AuthUser,
|
||||||
|
) {
|
||||||
|
const businessId = BigInt(businessIdRaw);
|
||||||
|
const methodId = BigInt(methodIdRaw);
|
||||||
|
await this.assertPermission(businessId, actor.id, 'products.update');
|
||||||
|
await this.assertModuleEnabled(businessId);
|
||||||
|
|
||||||
|
await this.findOrThrow(businessId, methodId);
|
||||||
|
await this.prisma.storeLoanMethod.delete({ where: { id: methodId } });
|
||||||
|
|
||||||
|
return { message: 'Loan method deleted successfully' };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Public website: active methods when store + store_installments are enabled. */
|
||||||
|
async listPublic(host: string) {
|
||||||
|
const business = await this.tenant.resolveBusinessByDomain(host);
|
||||||
|
const enabled = this.isModuleEnabled(business.settings);
|
||||||
|
|
||||||
|
if (!enabled) {
|
||||||
|
return { enabled: false, items: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = await this.prisma.storeLoanMethod.findMany({
|
||||||
|
where: { businessId: business.id, isActive: true },
|
||||||
|
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'desc' }],
|
||||||
|
include: { imageMedia: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
enabled: true,
|
||||||
|
items: items.map((item) => this.serializePublic(item)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getOnePublic(host: string, methodIdRaw: string) {
|
||||||
|
const business = await this.tenant.resolveBusinessByDomain(host);
|
||||||
|
if (!this.isModuleEnabled(business.settings)) {
|
||||||
|
throw new NotFoundException('Loan method not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const methodId = BigInt(methodIdRaw);
|
||||||
|
const method = await this.prisma.storeLoanMethod.findFirst({
|
||||||
|
where: { id: methodId, businessId: business.id, isActive: true },
|
||||||
|
include: { imageMedia: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!method) {
|
||||||
|
throw new NotFoundException('Loan method not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return { method: this.serializePublic(method) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Public calculator. Always requires methodId — when a site has multiple
|
||||||
|
* credit methods, the website must ask the shopper which method first.
|
||||||
|
*/
|
||||||
|
async calculatePublic(host: string, dto: CalculateStoreLoanMethodDto) {
|
||||||
|
const business = await this.tenant.resolveBusinessByDomain(host);
|
||||||
|
if (!this.isModuleEnabled(business.settings)) {
|
||||||
|
throw new NotFoundException('Loan method not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const methodId = BigInt(dto.methodId);
|
||||||
|
const method = await this.prisma.storeLoanMethod.findFirst({
|
||||||
|
where: { id: methodId, businessId: business.id, isActive: true },
|
||||||
|
include: { imageMedia: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!method) {
|
||||||
|
throw new NotFoundException('Loan method not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalValue = Math.round(dto.totalValue);
|
||||||
|
const creditAmount = Math.round(dto.creditAmount);
|
||||||
|
const months = Math.round(dto.months);
|
||||||
|
const minShopping = Number(method.minShoppingAmount);
|
||||||
|
const minAmount = Number(method.minAmount);
|
||||||
|
const maxAmount = Number(method.maxAmount);
|
||||||
|
const interestPercent = Number(method.interest);
|
||||||
|
|
||||||
|
if (totalValue < minShopping) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Total purchase must be at least ${minShopping}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (creditAmount < minAmount || creditAmount > maxAmount) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Credit amount must be between ${minAmount} and ${maxAmount}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (creditAmount > totalValue) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Credit amount cannot exceed total purchase value',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const monthOptions = this.listReturnMonthOptions(
|
||||||
|
method.returnMonthsMin,
|
||||||
|
method.returnMonthsMax,
|
||||||
|
method.returnMonthsStep,
|
||||||
|
);
|
||||||
|
if (!monthOptions.includes(months)) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Months must be one of: ${monthOptions.join(', ')}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const plan = this.calculatePlan({
|
||||||
|
totalValue,
|
||||||
|
creditAmount,
|
||||||
|
interestPercent,
|
||||||
|
months,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
method: this.serializePublic(method),
|
||||||
|
plan,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private listReturnMonthOptions(min: number, max: number, step: number) {
|
||||||
|
if (min > max || step < 1) return [] as number[];
|
||||||
|
const options: number[] = [];
|
||||||
|
for (let value = min; value <= max; value += step) {
|
||||||
|
options.push(value);
|
||||||
|
}
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flat annual interest prorated by term months:
|
||||||
|
* fee = credit × (interest% / 100) × (months / 12)
|
||||||
|
*/
|
||||||
|
private calculatePlan(input: {
|
||||||
|
totalValue: number;
|
||||||
|
creditAmount: number;
|
||||||
|
interestPercent: number;
|
||||||
|
months: number;
|
||||||
|
startDate?: Date;
|
||||||
|
}) {
|
||||||
|
const cashPayment = input.totalValue - input.creditAmount;
|
||||||
|
const interestFee = Math.round(
|
||||||
|
input.creditAmount *
|
||||||
|
(input.interestPercent / 100) *
|
||||||
|
(input.months / 12),
|
||||||
|
);
|
||||||
|
const totalRepay = input.creditAmount + interestFee;
|
||||||
|
const basePayment = Math.floor(totalRepay / input.months);
|
||||||
|
const remainder = totalRepay - basePayment * input.months;
|
||||||
|
|
||||||
|
const start = input.startDate ? new Date(input.startDate) : new Date();
|
||||||
|
const installments: Array<{
|
||||||
|
index: number;
|
||||||
|
dueDate: string;
|
||||||
|
amount: number;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
for (let index = 1; index <= input.months; index += 1) {
|
||||||
|
const dueDate = new Date(start);
|
||||||
|
dueDate.setMonth(dueDate.getMonth() + index);
|
||||||
|
const amount = basePayment + (index === input.months ? remainder : 0);
|
||||||
|
installments.push({
|
||||||
|
index,
|
||||||
|
dueDate: dueDate.toISOString(),
|
||||||
|
amount,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalValue: input.totalValue,
|
||||||
|
creditAmount: input.creditAmount,
|
||||||
|
cashPayment,
|
||||||
|
interestFee,
|
||||||
|
totalRepay,
|
||||||
|
months: input.months,
|
||||||
|
installments,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private serializePublic(method: LoanMethodWithImage) {
|
||||||
|
return {
|
||||||
|
id: method.id.toString(),
|
||||||
|
nameFa: method.nameFa,
|
||||||
|
nameEn: method.nameEn,
|
||||||
|
description: method.description,
|
||||||
|
imageUrl: method.imageMedia?.publicUrl ?? null,
|
||||||
|
imageAspectRatio: method.imageAspectRatio,
|
||||||
|
minShoppingAmount: Number(method.minShoppingAmount),
|
||||||
|
minAmount: Number(method.minAmount),
|
||||||
|
maxAmount: Number(method.maxAmount),
|
||||||
|
interest: Number(method.interest),
|
||||||
|
returnMonthsMin: method.returnMonthsMin,
|
||||||
|
returnMonthsMax: method.returnMonthsMax,
|
||||||
|
returnMonthsStep: method.returnMonthsStep,
|
||||||
|
returnMonthOptions: this.listReturnMonthOptions(
|
||||||
|
method.returnMonthsMin,
|
||||||
|
method.returnMonthsMax,
|
||||||
|
method.returnMonthsStep,
|
||||||
|
),
|
||||||
|
sortOrder: method.sortOrder,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private isModuleEnabled(settingsRaw: unknown) {
|
||||||
|
const settings = normalizeBusinessSettings(settingsRaw);
|
||||||
|
return (
|
||||||
|
settings.modules.enabled.includes('store') &&
|
||||||
|
settings.modules.enabled.includes('store_installments')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async findOrThrow(businessId: bigint, methodId: bigint) {
|
||||||
|
const method = await this.prisma.storeLoanMethod.findFirst({
|
||||||
|
where: { id: methodId, businessId },
|
||||||
|
include: { imageMedia: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!method) {
|
||||||
|
throw new NotFoundException('Loan method not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return method;
|
||||||
|
}
|
||||||
|
|
||||||
|
private serialize(method: LoanMethodWithImage) {
|
||||||
|
return {
|
||||||
|
id: method.id.toString(),
|
||||||
|
businessId: method.businessId.toString(),
|
||||||
|
nameFa: method.nameFa,
|
||||||
|
nameEn: method.nameEn,
|
||||||
|
description: method.description,
|
||||||
|
imageMediaId: method.imageMediaId?.toString() ?? null,
|
||||||
|
imageUrl: method.imageMedia?.publicUrl ?? null,
|
||||||
|
imageAspectRatio: method.imageAspectRatio,
|
||||||
|
minShoppingAmount: Number(method.minShoppingAmount),
|
||||||
|
minAmount: Number(method.minAmount),
|
||||||
|
maxAmount: Number(method.maxAmount),
|
||||||
|
interest: Number(method.interest),
|
||||||
|
returnMonthsMin: method.returnMonthsMin,
|
||||||
|
returnMonthsMax: method.returnMonthsMax,
|
||||||
|
returnMonthsStep: method.returnMonthsStep,
|
||||||
|
sortOrder: method.sortOrder,
|
||||||
|
isActive: method.isActive,
|
||||||
|
createdAt: method.createdAt,
|
||||||
|
updatedAt: method.updatedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertAmountRanges(input: {
|
||||||
|
minShoppingAmount: number;
|
||||||
|
minAmount: number;
|
||||||
|
maxAmount: number;
|
||||||
|
}) {
|
||||||
|
if (input.minAmount > input.maxAmount) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Minimum loan amount cannot be greater than maximum loan amount',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertReturnMonths(min: number, max: number, step: number) {
|
||||||
|
if (min > max) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Minimum return months cannot be greater than maximum return months',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(STORE_LOAN_MONTH_STEPS as readonly number[]).includes(step)) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Return months step must be one of: ${STORE_LOAN_MONTH_STEPS.join(', ')}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((max - min) % step !== 0) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Maximum return months must be reachable from minimum using the step',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertImageMedia(businessId: bigint, mediaId: bigint) {
|
||||||
|
const media = await this.prisma.media.findFirst({
|
||||||
|
where: { id: mediaId, businessId },
|
||||||
|
select: { id: true, mimeType: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!media) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Loan method image media not found for this business',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!media.mimeType.startsWith('image/')) {
|
||||||
|
throw new BadRequestException('Loan method image must be an image file');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertModuleEnabled(businessId: bigint) {
|
||||||
|
const business = await this.prisma.business.findFirst({
|
||||||
|
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') ||
|
||||||
|
!settings.modules.enabled.includes('store_installments')
|
||||||
|
) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
'Installments & facilities module is not enabled for this business',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,11 @@ import { StoreSpecialsAiService } from './store-specials-ai.service';
|
|||||||
import { StoreSpecialsService } from './store-specials.service';
|
import { StoreSpecialsService } from './store-specials.service';
|
||||||
import { StoreItemsController, PublicStoreItemsController } from './store-items.controller';
|
import { StoreItemsController, PublicStoreItemsController } from './store-items.controller';
|
||||||
import { StoreItemsService } from './store-items.service';
|
import { StoreItemsService } from './store-items.service';
|
||||||
|
import {
|
||||||
|
PublicStoreLoanMethodsController,
|
||||||
|
StoreLoanMethodsController,
|
||||||
|
} from './store-loan-methods.controller';
|
||||||
|
import { StoreLoanMethodsService } from './store-loan-methods.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [AuthModule, TenantModule, WebsiteAnalyticsModule],
|
imports: [AuthModule, TenantModule, WebsiteAnalyticsModule],
|
||||||
@@ -18,8 +23,15 @@ import { StoreItemsService } from './store-items.service';
|
|||||||
PublicStoreItemsController,
|
PublicStoreItemsController,
|
||||||
PublicStoreSpecialsController,
|
PublicStoreSpecialsController,
|
||||||
StoreSpecialsController,
|
StoreSpecialsController,
|
||||||
|
PublicStoreLoanMethodsController,
|
||||||
|
StoreLoanMethodsController,
|
||||||
],
|
],
|
||||||
providers: [StoreItemsService, StoreSpecialsService, StoreSpecialsAiService],
|
providers: [
|
||||||
exports: [StoreItemsService, StoreSpecialsService],
|
StoreItemsService,
|
||||||
|
StoreSpecialsService,
|
||||||
|
StoreSpecialsAiService,
|
||||||
|
StoreLoanMethodsService,
|
||||||
|
],
|
||||||
|
exports: [StoreItemsService, StoreSpecialsService, StoreLoanMethodsService],
|
||||||
})
|
})
|
||||||
export class StoreModule {}
|
export class StoreModule {}
|
||||||
|
|||||||
@@ -54,8 +54,9 @@ After changing `next.config`, commit, push, and redeploy so PM2 switches to stan
|
|||||||
2. `GET /tenants/{domain}/website/favicon` → `faviconUrl` for `<link rel="icon">` / Next.js `metadata.icons` (falls back to `logoUrl` when no dedicated favicon). Also returns `logoUrl`, `logoDarkUrl`, `hasDedicatedFavicon`.
|
2. `GET /tenants/{domain}/website/favicon` → `faviconUrl` for `<link rel="icon">` / Next.js `metadata.icons` (falls back to `logoUrl` when no dedicated favicon). Also returns `logoUrl`, `logoDarkUrl`, `hasDedicatedFavicon`.
|
||||||
3. Homepage: business-info, **static-images**, sliders, category-groups, brand-groups, store-specials (`source` repeats the tenant setting; items are store listings when `source` is `store_item`)
|
3. Homepage: business-info, **static-images**, sliders, category-groups, brand-groups, store-specials (`source` repeats the tenant setting; items are store listings when `source` is `store_item`)
|
||||||
4. Catalog: categories, products (`GET /products/{slug}` includes `relatedProducts`: same category then same brand, in-stock first), store-items (`name` instant search: in-stock first, then `updatedAt`), **user-products** (customer stock listings). Portfolios list newest first (`sortOrder` desc, then `publishedAt` / `createdAt` desc); each portfolio may include nullable `projectUrl` (external website link). List filters: products/blogs/portfolios/videos accept `?tag=` (exact match on `metadata.tags`).
|
4. Catalog: categories, products (`GET /products/{slug}` includes `relatedProducts`: same category then same brand, in-stock first), store-items (`name` instant search: in-stock first, then `updatedAt`), **user-products** (customer stock listings). Portfolios list newest first (`sortOrder` desc, then `publishedAt` / `createdAt` desc); each portfolio may include nullable `projectUrl` (external website link). List filters: products/blogs/portfolios/videos accept `?tag=` (exact match on `metadata.tags`).
|
||||||
5. Shopping cart on **this** site only: header icon + badge + mini-cart popup. Persist a **guest cart** and **Continue** to `https://customer.<WEBSITE_DOMAIN>` (see Shopping cart). Do not implement login or checkout here.
|
5. **Installments / credit** (only when `enabledModules` includes `store` and `store_installments`, or list returns `enabled: true`): see Installments below.
|
||||||
6. Bank payment callbacks stay on the store apex via nginx (`https://<WEBSITE_DOMAIN>/meshkee/payments/{gateway}/callback`) — infrastructure only. The website app does **not** implement payment or checkout pages; that UI is the customer dashboard.
|
6. Shopping cart on **this** site only: header icon + badge + mini-cart popup. Persist a **guest cart** and **Continue** to `https://customer.<WEBSITE_DOMAIN>` (see Shopping cart). Do not implement login or checkout here.
|
||||||
|
7. Bank payment callbacks stay on the store apex via nginx (`https://<WEBSITE_DOMAIN>/meshkee/payments/{gateway}/callback`) — infrastructure only. The website app does **not** implement payment or checkout pages; that UI is the customer dashboard.
|
||||||
|
|
||||||
### Favicon + logos
|
### Favicon + logos
|
||||||
- `GET /tenants/{domain}/website/favicon` → `{ faviconUrl, logoUrl, logoDarkUrl, hasDedicatedFavicon }`
|
- `GET /tenants/{domain}/website/favicon` → `{ faviconUrl, logoUrl, logoDarkUrl, hasDedicatedFavicon }`
|
||||||
@@ -164,6 +165,34 @@ The dashboard reads `guestCart` (query or hash), then `localStorage`, then the s
|
|||||||
**Wrong:** storefront `/cart` or `/checkout` pages; `POST /businesses/{id}/cart/checkout`; a shop-built login.
|
**Wrong:** storefront `/cart` or `/checkout` pages; `POST /businesses/{id}/cart/checkout`; a shop-built login.
|
||||||
**Right:** local guest mini-cart → redirect to `customer.<WEBSITE_DOMAIN>`.
|
**Right:** local guest mini-cart → redirect to `customer.<WEBSITE_DOMAIN>`.
|
||||||
|
|
||||||
|
### Installments & credit methods
|
||||||
|
|
||||||
|
Optional module (`store` + `store_installments`). Hide the UI when `GET /tenants/{domain}/store-loan-methods` returns `enabled: false` or empty `items`.
|
||||||
|
|
||||||
|
| Step | Endpoint |
|
||||||
|
|------|----------|
|
||||||
|
| 1. List methods | `GET /tenants/{domain}/store-loan-methods` → `{ enabled, items[] }` |
|
||||||
|
| 2. Pick method | **If `items.length > 1`, ask the shopper which method** (name + image). If exactly one, auto-select. Never skip this when multiple exist. |
|
||||||
|
| 3. Collect inputs | Purchase total (`totalValue` ≥ `minShoppingAmount`), credit amount (within `minAmount`–`maxAmount`, ≤ total), months from `returnMonthOptions` / slider with `returnMonthsStep` |
|
||||||
|
| 4. Calculate | `POST /tenants/{domain}/store-loan-methods/calculate` with `{ methodId, totalValue, creditAmount, months }` |
|
||||||
|
|
||||||
|
```json
|
||||||
|
POST /tenants/{domain}/store-loan-methods/calculate
|
||||||
|
{
|
||||||
|
"methodId": "1",
|
||||||
|
"totalValue": 5000000,
|
||||||
|
"creditAmount": 2000000,
|
||||||
|
"months": 12
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Response `{ method, plan }` — `plan` has `cashPayment`, `interestFee`, `totalRepay`, and `installments[]` (`index`, `dueDate`, `amount`). Fee = `round(credit × (interest/100) × (months/12))`.
|
||||||
|
|
||||||
|
Optional: `GET /tenants/{domain}/store-loan-methods/{methodId}` for a single method.
|
||||||
|
|
||||||
|
**Wrong:** calculating without a chosen `methodId`, or auto-picking the first method when several are listed.
|
||||||
|
**Right:** method picker first (when count > 1) → then amounts → calculate.
|
||||||
|
|
||||||
### Checkout & payments (customer dashboard — not this website)
|
### Checkout & payments (customer dashboard — not this website)
|
||||||
|
|
||||||
OpenAPI **Cart** / checkout / payment-method routes are for `https://customer.<WEBSITE_DOMAIN>`, not storefront JavaScript.
|
OpenAPI **Cart** / checkout / payment-method routes are for `https://customer.<WEBSITE_DOMAIN>`, not storefront JavaScript.
|
||||||
|
|||||||
@@ -69,6 +69,10 @@
|
|||||||
"key": "storeItemVariantId",
|
"key": "storeItemVariantId",
|
||||||
"value": ""
|
"value": ""
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"key": "loanMethodId",
|
||||||
|
"value": ""
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"key": "cartItemId",
|
"key": "cartItemId",
|
||||||
"value": ""
|
"value": ""
|
||||||
@@ -1592,6 +1596,52 @@
|
|||||||
"method": "GET",
|
"method": "GET",
|
||||||
"url": "{{baseUrl}}/tenants/{{domain}}/store-specials"
|
"url": "{{baseUrl}}/tenants/{{domain}}/store-specials"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "List store loan methods (website)",
|
||||||
|
"event": [
|
||||||
|
{
|
||||||
|
"listen": "test",
|
||||||
|
"script": {
|
||||||
|
"exec": [
|
||||||
|
"if (pm.response.code === 200) {",
|
||||||
|
" const json = pm.response.json();",
|
||||||
|
" const first = json.items && json.items[0];",
|
||||||
|
" if (first && first.id) pm.collectionVariables.set('loanMethodId', first.id);",
|
||||||
|
"}"
|
||||||
|
],
|
||||||
|
"type": "text/javascript"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"request": {
|
||||||
|
"method": "GET",
|
||||||
|
"url": "{{baseUrl}}/tenants/{{domain}}/store-loan-methods"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Get store loan method (website)",
|
||||||
|
"request": {
|
||||||
|
"method": "GET",
|
||||||
|
"url": "{{baseUrl}}/tenants/{{domain}}/store-loan-methods/{{loanMethodId}}"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Calculate store loan plan (website)",
|
||||||
|
"request": {
|
||||||
|
"method": "POST",
|
||||||
|
"header": [
|
||||||
|
{
|
||||||
|
"key": "Content-Type",
|
||||||
|
"value": "application/json"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"body": {
|
||||||
|
"mode": "raw",
|
||||||
|
"raw": "{\n \"methodId\": \"{{loanMethodId}}\",\n \"totalValue\": 5000000,\n \"creditAmount\": 2000000,\n \"months\": 12\n}"
|
||||||
|
},
|
||||||
|
"url": "{{baseUrl}}/tenants/{{domain}}/store-loan-methods/calculate"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -44,6 +44,10 @@
|
|||||||
{
|
{
|
||||||
"name": "Store"
|
"name": "Store"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "Installments",
|
||||||
|
"description": "Credit / installment methods (`store` + `store_installments`). List methods first; if more than one, ask the shopper which method before calculate. POST .../calculate always requires methodId."
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "Torob",
|
"name": "Torob",
|
||||||
"description": "Product API v3 for Torob. Called by Torob (not storefront JS). Nginx on the shop apex proxies POST /torob_api/v3/products. Only tenants with the store module enabled; otherwise 404."
|
"description": "Product API v3 for Torob. Called by Torob (not storefront JS). Nginx on the shop apex proxies POST /torob_api/v3/products. Only tenants with the store module enabled; otherwise 404."
|
||||||
@@ -835,6 +839,122 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/tenants/{domain}/store-loan-methods": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"Store",
|
||||||
|
"Installments"
|
||||||
|
],
|
||||||
|
"summary": "Active installment / credit methods",
|
||||||
|
"description": "Requires business modules `store` + `store_installments`. When disabled, returns `{ enabled: false, items: [] }`.\n\n**Multi-method UX:** if `items.length > 1`, ask the shopper which method before collecting amounts or calling calculate. If exactly one, auto-select it. Always pass that `methodId` to `POST .../calculate`.",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"$ref": "#/components/parameters/domain"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "{ enabled: boolean, items: StoreLoanMethod[] } — each item has id, nameFa, nameEn, description, imageUrl, imageAspectRatio, minShoppingAmount, minAmount, maxAmount, interest, returnMonthsMin/Max/Step, returnMonthOptions[], sortOrder"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/tenants/{domain}/store-loan-methods/calculate": {
|
||||||
|
"post": {
|
||||||
|
"tags": [
|
||||||
|
"Store",
|
||||||
|
"Installments"
|
||||||
|
],
|
||||||
|
"summary": "Calculate installment plan for a chosen method",
|
||||||
|
"description": "`methodId` is **required**. When the site has multiple credit methods, list them first and let the shopper pick one before calling this endpoint.\n\nFee formula (flat annual interest prorated by term): `interestFee = round(creditAmount × (interest/100) × (months/12))`.",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"$ref": "#/components/parameters/domain"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"methodId",
|
||||||
|
"totalValue",
|
||||||
|
"creditAmount",
|
||||||
|
"months"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"methodId": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Chosen method id from GET .../store-loan-methods"
|
||||||
|
},
|
||||||
|
"totalValue": {
|
||||||
|
"type": "number",
|
||||||
|
"description": "Total purchase amount (must be ≥ method.minShoppingAmount)"
|
||||||
|
},
|
||||||
|
"creditAmount": {
|
||||||
|
"type": "number",
|
||||||
|
"description": "Credit portion (within method min/max and ≤ totalValue)"
|
||||||
|
},
|
||||||
|
"months": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Must be in method.returnMonthOptions"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"example": {
|
||||||
|
"methodId": "1",
|
||||||
|
"totalValue": 5000000,
|
||||||
|
"creditAmount": 2000000,
|
||||||
|
"months": 12
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "{ method, plan } — plan has totalValue, creditAmount, cashPayment, interestFee, totalRepay, months, installments[{ index, dueDate, amount }]"
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Validation failed (amount/months out of range)"
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "Module off or method not found / inactive"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/tenants/{domain}/store-loan-methods/{methodId}": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"Store",
|
||||||
|
"Installments"
|
||||||
|
],
|
||||||
|
"summary": "One active installment / credit method",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"$ref": "#/components/parameters/domain"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "methodId",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "{ method: StoreLoanMethod }"
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "Module off or method not found / inactive"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/tenants/{domain}/categories": {
|
"/tenants/{domain}/categories": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
|
|||||||
Reference in New Issue
Block a user