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
|
||||
Product 1──* ProductTechnicalFieldValue → CategoryTechnicalFormField
|
||||
|
||||
Business 1──* StoreLoanMethod (installments / facilities; module `store_installments`)
|
||||
|
||||
UserProduct *── Category (via CategoryAssignment, entityType user_product → product categories)
|
||||
UserProduct 1──* UserProductTechnicalFieldValue → CategoryTechnicalFormField
|
||||
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 |
|
||||
| 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-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/brand-groups` | Homepage brand rows |
|
||||
| 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 } }`.
|
||||
- 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:**
|
||||
|
||||
```json
|
||||
@@ -667,6 +708,7 @@ See `.env.example` for the full list. Key groups:
|
||||
- Products CRUD
|
||||
- Product variation values (which options a product offers)
|
||||
- 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
|
||||
- Shopping cart + checkout + orders (customer + admin)
|
||||
- Online e-payment (Mellat + ZarinPal; stubs for SEP / Snapp Pay / DigiPay)
|
||||
|
||||
@@ -160,6 +160,7 @@ model Business {
|
||||
shoppingCards ShoppingCard[]
|
||||
storeItemVariants StoreItemVariant[]
|
||||
storeItems StoreItem[]
|
||||
storeLoanMethods StoreLoanMethod[]
|
||||
storeSpecials StoreSpecial[]
|
||||
transactions Transaction[]
|
||||
userProductTechnicalFieldValues UserProductTechnicalFieldValue[]
|
||||
@@ -379,6 +380,7 @@ model Media {
|
||||
oldId BigInt? @map("old_id")
|
||||
blogs blogs[]
|
||||
brandImages Brand[]
|
||||
storeLoanMethodImages StoreLoanMethod[]
|
||||
faviconBusinesses Business[] @relation("BusinessFavicon")
|
||||
logoBusinesses Business[] @relation("BusinessLogo")
|
||||
logoDarkBusinesses Business[] @relation("BusinessLogoDark")
|
||||
@@ -1220,6 +1222,34 @@ model StoreSpecial {
|
||||
@@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 {
|
||||
id BigInt @id @default(autoincrement())
|
||||
group_id BigInt
|
||||
|
||||
@@ -188,6 +188,7 @@ export type WebsiteSitemapConfig = {
|
||||
export const BUSINESS_DASHBOARD_MODULE_IDS = [
|
||||
'products',
|
||||
'store',
|
||||
'store_installments',
|
||||
'portfolio',
|
||||
'blog',
|
||||
'warehouse',
|
||||
@@ -300,7 +301,10 @@ export const DEFAULT_NEW_BUSINESS_MODULES: BusinessModuleId[] = [
|
||||
*/
|
||||
export const DEFAULT_ENABLED_BUSINESS_MODULES: BusinessModuleId[] =
|
||||
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] = [
|
||||
|
||||
@@ -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 { StoreItemsController, PublicStoreItemsController } from './store-items.controller';
|
||||
import { StoreItemsService } from './store-items.service';
|
||||
import {
|
||||
PublicStoreLoanMethodsController,
|
||||
StoreLoanMethodsController,
|
||||
} from './store-loan-methods.controller';
|
||||
import { StoreLoanMethodsService } from './store-loan-methods.service';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule, TenantModule, WebsiteAnalyticsModule],
|
||||
@@ -18,8 +23,15 @@ import { StoreItemsService } from './store-items.service';
|
||||
PublicStoreItemsController,
|
||||
PublicStoreSpecialsController,
|
||||
StoreSpecialsController,
|
||||
PublicStoreLoanMethodsController,
|
||||
StoreLoanMethodsController,
|
||||
],
|
||||
providers: [StoreItemsService, StoreSpecialsService, StoreSpecialsAiService],
|
||||
exports: [StoreItemsService, StoreSpecialsService],
|
||||
providers: [
|
||||
StoreItemsService,
|
||||
StoreSpecialsService,
|
||||
StoreSpecialsAiService,
|
||||
StoreLoanMethodsService,
|
||||
],
|
||||
exports: [StoreItemsService, StoreSpecialsService, StoreLoanMethodsService],
|
||||
})
|
||||
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`.
|
||||
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`).
|
||||
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.
|
||||
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.
|
||||
5. **Installments / credit** (only when `enabledModules` includes `store` and `store_installments`, or list returns `enabled: true`): see Installments below.
|
||||
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
|
||||
- `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.
|
||||
**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)
|
||||
|
||||
OpenAPI **Cart** / checkout / payment-method routes are for `https://customer.<WEBSITE_DOMAIN>`, not storefront JavaScript.
|
||||
|
||||
@@ -69,6 +69,10 @@
|
||||
"key": "storeItemVariantId",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"key": "loanMethodId",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"key": "cartItemId",
|
||||
"value": ""
|
||||
@@ -1592,6 +1596,52 @@
|
||||
"method": "GET",
|
||||
"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": "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",
|
||||
"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": {
|
||||
"get": {
|
||||
"tags": [
|
||||
|
||||
Reference in New Issue
Block a user