diff --git a/database/migrations/098_store_loan_methods.sql b/database/migrations/098_store_loan_methods.sql
new file mode 100644
index 0000000..84b55fc
--- /dev/null
+++ b/database/migrations/098_store_loan_methods.sql
@@ -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();
diff --git a/docs/PROJECT_CONTEXT.md b/docs/PROJECT_CONTEXT.md
index 433cc83..22fec68 100644
--- a/docs/PROJECT_CONTEXT.md
+++ b/docs/PROJECT_CONTEXT.md
@@ -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)
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 4abc546..7592a4d 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -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
diff --git a/src/business-settings/business-settings.types.ts b/src/business-settings/business-settings.types.ts
index d73e145..71475b0 100644
--- a/src/business-settings/business-settings.types.ts
+++ b/src/business-settings/business-settings.types.ts
@@ -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] = [
diff --git a/src/store/dto/store-loan-methods.dto.ts b/src/store/dto/store-loan-methods.dto.ts
new file mode 100644
index 0000000..c75e946
--- /dev/null
+++ b/src/store/dto/store-loan-methods.dto.ts
@@ -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;
+}
diff --git a/src/store/store-loan-methods.controller.ts b/src/store/store-loan-methods.controller.ts
new file mode 100644
index 0000000..9afc336
--- /dev/null
+++ b/src/store/store-loan-methods.controller.ts
@@ -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);
+ }
+}
diff --git a/src/store/store-loan-methods.service.ts b/src/store/store-loan-methods.service.ts
new file mode 100644
index 0000000..618236b
--- /dev/null
+++ b/src/store/store-loan-methods.service.ts
@@ -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`,
+ );
+ }
+ }
+}
diff --git a/src/store/store.module.ts b/src/store/store.module.ts
index cde8ec6..60f830f 100644
--- a/src/store/store.module.ts
+++ b/src/store/store.module.ts
@@ -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 {}
diff --git a/src/website-docs/static/AI_PROMPT.md b/src/website-docs/static/AI_PROMPT.md
index c0581d8..164ddfb 100644
--- a/src/website-docs/static/AI_PROMPT.md
+++ b/src/website-docs/static/AI_PROMPT.md
@@ -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 `` / 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.` (see Shopping cart). Do not implement login or checkout here.
-6. Bank payment callbacks stay on the store apex via nginx (`https:///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.` (see Shopping cart). Do not implement login or checkout here.
+7. Bank payment callbacks stay on the store apex via nginx (`https:///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.`.
+### 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.`, not storefront JavaScript.
diff --git a/src/website-docs/static/Meshkee-Website-API.postman_collection.json b/src/website-docs/static/Meshkee-Website-API.postman_collection.json
index dec8ee1..ca4e5b2 100644
--- a/src/website-docs/static/Meshkee-Website-API.postman_collection.json
+++ b/src/website-docs/static/Meshkee-Website-API.postman_collection.json
@@ -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"
+ }
}
]
},
diff --git a/src/website-docs/static/openapi.json b/src/website-docs/static/openapi.json
index 78b1746..81cf498 100644
--- a/src/website-docs/static/openapi.json
+++ b/src/website-docs/static/openapi.json
@@ -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": [