From 4a9e10e614c6741143c64021f331cd484360558f Mon Sep 17 00:00:00 2001 From: Alireza Hassani Date: Thu, 24 Sep 2026 23:17:51 +0330 Subject: [PATCH] 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 --- .../migrations/098_store_loan_methods.sql | 37 ++ docs/PROJECT_CONTEXT.md | 42 ++ prisma/schema.prisma | 30 + .../business-settings.types.ts | 6 +- src/store/dto/store-loan-methods.dto.ts | 206 +++++++ src/store/store-loan-methods.controller.ts | 103 ++++ src/store/store-loan-methods.service.ts | 571 ++++++++++++++++++ src/store/store.module.ts | 16 +- src/website-docs/static/AI_PROMPT.md | 33 +- ...eshkee-Website-API.postman_collection.json | 50 ++ src/website-docs/static/openapi.json | 120 ++++ 11 files changed, 1209 insertions(+), 5 deletions(-) create mode 100644 database/migrations/098_store_loan_methods.sql create mode 100644 src/store/dto/store-loan-methods.dto.ts create mode 100644 src/store/store-loan-methods.controller.ts create mode 100644 src/store/store-loan-methods.service.ts 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": [