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:
Alireza Hassani
2026-09-24 23:17:51 +03:30
co-authored by Cursor
parent 2a01873c95
commit 4a9e10e614
11 changed files with 1209 additions and 5 deletions
@@ -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] = [
+206
View File
@@ -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;
}
+103
View File
@@ -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);
}
}
+571
View File
@@ -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`,
);
}
}
}
+14 -2
View File
@@ -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 {}
+31 -2
View File
@@ -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"
}
}
]
},
+120
View File
@@ -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": [