Add ZarinPal e-payment and related checkout/search fixes.

Business settings can store ZarinPal merchant credentials, and the payment registry can initiate and verify ZarinPal alongside Mellat.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-08-16 01:45:41 +03:30
co-authored by Cursor
parent c76c3967bb
commit b991a60cbc
19 changed files with 542 additions and 39 deletions
+3 -3
View File
@@ -349,9 +349,9 @@ Base: `/tenants/:host/user-products`
| Method | Path | Description |
|--------|------|-------------|
| GET | `/payments/methods` | Enabled gateways for this business (no secrets). Also on `GET /tenants/:host` as `ePayment` |
| POST/GET | `/payments/:gateway/callback` | Bank return URL (Mellat first). Verifies/settles, then 303 → `returnUrl?status=…` |
| POST/GET | `/payments/:gateway/callback` | Bank/gateway return URL (Mellat, ZarinPal). Verifies, then 303 → `returnUrl?status=…` |
Gateway credentials live in `businesses.settings.store.ePayment` (dashboard: Website → E-Payment). Adapter registry supports Mellat now; stubs reserved for `sep`, `snappay`, `digipay`, `zarinpal`.
Gateway credentials live in `businesses.settings.store.ePayment` (dashboard: **Settings**). Adapter registry: **Mellat** + **ZarinPal**; stubs reserved for `sep`, `snappay`, `digipay`.
#### Orders
@@ -619,7 +619,7 @@ See `.env.example` for the full list. Key groups:
- Product variation values (which options a product offers)
- Store items / product variants (price, stock, SKU)
- Shopping cart + checkout + orders (customer + admin)
- Online e-payment (Mellat first; multi-gateway registry for SEP / Snapp Pay / DigiPay / Zarinpal)
- Online e-payment (Mellat + ZarinPal; stubs for SEP / Snapp Pay / DigiPay)
- Product technical info
- Category variations & technical forms
- Tenant resolution by domain
+2 -2
View File
@@ -31,10 +31,10 @@ You are building a **Meshkee business website (storefront)**. You must use the M
### Typical bootstrap sequence
1. `GET /tenants/{domain}` → branding + `businessId`
2. Homepage: business-info, sliders, category-groups, brand-groups, store-specials
3. Catalog: categories, products, store-items, **user-products** (customer stock listings)
3. Catalog: categories, products, store-items (`name` instant search: in-stock first, then `updatedAt`), **user-products** (customer stock listings)
4. Auth: register/login → store tokens. Optional: `POST /auth/send-otp` then `POST /auth/login-otp` (passwordless) or `POST /auth/reset-password` (forgot password). `POST /auth/verify-otp` only marks the cell verified (no tokens).
5. Cart checkout with `addressId` or inline `shippingAddress` + `payment`
- For online pay: `payment.type = "e_payment_gate"`, `gatewayType` (e.g. `"mellat"`), and absolute `returnUrl`
- For online pay: `payment.type = "e_payment_gate"`, `gatewayType` (e.g. `"mellat"` or `"zarinpal"`), and absolute `returnUrl`
- Response includes `payment.redirect` `{ method, url, fields }` — POST/redirect shopper to the bank
- Bank callback hits API then redirects to `returnUrl?status=success|failed&orderId=…`
- Enabled gateways: `GET /tenants/{domain}` → `ePayment`, or `GET /businesses/{businessId}/payments/methods`
@@ -1716,6 +1716,29 @@
"url": "{{baseUrl}}/businesses/{{businessId}}/payments/mellat/callback",
"description": "Called by Mellat (not by the website). Verifies+settles, then 303 redirect to returnUrl."
}
},
{
"name": "ZarinPal callback (gateway → API)",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{baseUrl}}/businesses/{{businessId}}/payments/zarinpal/callback?Authority={{authority}}&Status=OK",
"host": ["{{baseUrl}}"],
"path": [
"businesses",
"{{businessId}}",
"payments",
"zarinpal",
"callback"
],
"query": [
{ "key": "Authority", "value": "{{authority}}" },
{ "key": "Status", "value": "OK" }
]
},
"description": "Called by ZarinPal (not by the website). Verifies payment, then 303 redirect to returnUrl."
}
}
]
},
+1
View File
@@ -645,6 +645,7 @@
{
"name": "name",
"in": "query",
"description": "Instant search on product title / nameFa. Results are ordered in-stock first (unlimited or qty > 0), then by updatedAt desc.",
"schema": {
"type": "string"
}
+10 -5
View File
@@ -89,7 +89,10 @@ if [[ -f .next/standalone/server.js ]]; then
cp -a .next/static .next/standalone/.next/static
if [[ -d public ]]; then
rm -rf .next/standalone/public
cp -a public .next/standalone/public
# Follow symlinks (e.g. public/images -> ../src/images) so standalone
# serves real files. cp -a alone would copy a broken relative link.
mkdir -p .next/standalone/public
cp -aL public/. .next/standalone/public/
fi
SLUG="$SLUG" ROOT="$ROOT" ECOSYSTEM="$ECOSYSTEM" node <<'NODE'
@@ -121,6 +124,7 @@ app.cwd = root + '/.next/standalone';
app.script = 'server.js';
app.args = '';
app.env = {
...(app.env || {}),
NODE_ENV: 'production',
PORT: String(port),
HOSTNAME: '127.0.0.1',
@@ -136,15 +140,16 @@ cfg.apps.forEach((entry, idx) => {
lines.push(` args: ${JSON.stringify(entry.args)},`);
}
lines.push(' env: {');
lines.push(` NODE_ENV: ${JSON.stringify(entry.env.NODE_ENV)},`);
lines.push(` PORT: ${JSON.stringify(entry.env.PORT)},`);
if (entry.env.HOSTNAME) {
lines.push(` HOSTNAME: ${JSON.stringify(entry.env.HOSTNAME)},`);
for (const [key, value] of Object.entries(entry.env || {})) {
lines.push(` ${key}: ${JSON.stringify(String(value))},`);
}
lines.push(' },');
lines.push(` error_file: ${JSON.stringify(entry.error_file)},`);
lines.push(` out_file: ${JSON.stringify(entry.out_file)},`);
lines.push(' time: true,');
if (entry.max_memory_restart) {
lines.push(` max_memory_restart: ${JSON.stringify(entry.max_memory_restart)},`);
}
lines.push(idx === cfg.apps.length - 1 ? ' }' : ' },');
});
lines.push(' ],');
@@ -148,6 +148,12 @@ export class BusinessSettingsService {
enabled:
dto.store.ePayment.gateways?.zarinpal?.enabled ??
currentEPayment.gateways.zarinpal.enabled,
merchantId:
dto.store.ePayment.gateways?.zarinpal?.merchantId ??
currentEPayment.gateways.zarinpal.merchantId,
sandbox:
dto.store.ePayment.gateways?.zarinpal?.sandbox ??
currentEPayment.gateways.zarinpal.sandbox,
},
},
}),
@@ -58,8 +58,17 @@ export type MellatGatewaySettings = {
password: string;
};
/** ZarinPal merchant credentials. */
export type ZarinpalGatewaySettings = {
enabled: boolean;
/** Merchant code from the ZarinPal panel. */
merchantId: string;
/** When true, use sandbox.zarinpal.com endpoints. */
sandbox: boolean;
};
/**
* Placeholder for gateways not yet wired (SEP, Snapp Pay, DigiPay, Zarinpal).
* Placeholder for gateways not yet wired (SEP, Snapp Pay, DigiPay).
* Credential fields are added when each adapter ships.
*/
export type StubGatewaySettings = {
@@ -71,7 +80,7 @@ export type PaymentGatewaysSettings = {
sep: StubGatewaySettings;
snappay: StubGatewaySettings;
digipay: StubGatewaySettings;
zarinpal: StubGatewaySettings;
zarinpal: ZarinpalGatewaySettings;
};
/** Online e-payment (website checkout). */
@@ -192,6 +201,12 @@ export const DEFAULT_STUB_GATEWAY_SETTINGS: StubGatewaySettings = {
enabled: false,
};
export const DEFAULT_ZARINPAL_GATEWAY_SETTINGS: ZarinpalGatewaySettings = {
enabled: false,
merchantId: '',
sandbox: false,
};
export const DEFAULT_EPAYMENT_SETTINGS: EPaymentSettings = {
enabled: false,
defaultGateway: null,
@@ -200,7 +215,7 @@ export const DEFAULT_EPAYMENT_SETTINGS: EPaymentSettings = {
sep: { ...DEFAULT_STUB_GATEWAY_SETTINGS },
snappay: { ...DEFAULT_STUB_GATEWAY_SETTINGS },
digipay: { ...DEFAULT_STUB_GATEWAY_SETTINGS },
zarinpal: { ...DEFAULT_STUB_GATEWAY_SETTINGS },
zarinpal: { ...DEFAULT_ZARINPAL_GATEWAY_SETTINGS },
},
};
@@ -15,6 +15,7 @@ import {
DEFAULT_MELLAT_GATEWAY_SETTINGS,
DEFAULT_ORDER_PROCESS_STEPS,
DEFAULT_STUB_GATEWAY_SETTINGS,
DEFAULT_ZARINPAL_GATEWAY_SETTINGS,
EPaymentSettings,
HOME_CHART_IDS,
MellatGatewaySettings,
@@ -25,6 +26,7 @@ import {
type PaymentGatewayId,
OrderProcessStep,
StubGatewaySettings,
ZarinpalGatewaySettings,
} from './business-settings.types';
import {
defaultOrderStepColor,
@@ -131,6 +133,24 @@ function readStubGateway(value: unknown): StubGatewaySettings {
};
}
function readZarinpalGateway(value: unknown): ZarinpalGatewaySettings {
const source = isRecord(value) ? value : {};
return {
enabled: readBoolean(
source.enabled,
DEFAULT_ZARINPAL_GATEWAY_SETTINGS.enabled,
),
merchantId: readString(
source.merchantId,
DEFAULT_ZARINPAL_GATEWAY_SETTINGS.merchantId,
),
sandbox: readBoolean(
source.sandbox,
DEFAULT_ZARINPAL_GATEWAY_SETTINGS.sandbox,
),
};
}
export function normalizeEPaymentSettings(value: unknown): EPaymentSettings {
const source = isRecord(value) ? value : {};
const gateways = isRecord(source.gateways) ? source.gateways : {};
@@ -146,7 +166,7 @@ export function normalizeEPaymentSettings(value: unknown): EPaymentSettings {
sep: readStubGateway(gateways.sep),
snappay: readStubGateway(gateways.snappay),
digipay: readStubGateway(gateways.digipay),
zarinpal: readStubGateway(gateways.zarinpal),
zarinpal: readZarinpalGateway(gateways.zarinpal),
},
};
}
@@ -177,7 +197,11 @@ export function mergeEPaymentSettings(
sep: { enabled: patch.gateways.sep.enabled },
snappay: { enabled: patch.gateways.snappay.enabled },
digipay: { enabled: patch.gateways.digipay.enabled },
zarinpal: { enabled: patch.gateways.zarinpal.enabled },
zarinpal: {
enabled: patch.gateways.zarinpal.enabled,
merchantId: patch.gateways.zarinpal.merchantId.trim(),
sandbox: patch.gateways.zarinpal.sandbox,
},
},
};
}
@@ -228,10 +252,13 @@ export function isGatewayReady(
g.password.length > 0
);
}
case 'zarinpal': {
const g = settings.gateways.zarinpal;
return g.enabled && g.merchantId.length > 0;
}
case 'sep':
case 'snappay':
case 'digipay':
case 'zarinpal':
return false;
default:
return false;
@@ -104,6 +104,20 @@ class StubGatewaySettingsDto {
enabled?: boolean;
}
class ZarinpalGatewaySettingsDto {
@IsOptional()
@IsBoolean()
enabled?: boolean;
@IsOptional()
@IsString()
merchantId?: string;
@IsOptional()
@IsBoolean()
sandbox?: boolean;
}
class PaymentGatewaysSettingsDto {
@IsOptional()
@ValidateNested()
@@ -127,8 +141,8 @@ class PaymentGatewaysSettingsDto {
@IsOptional()
@ValidateNested()
@Type(() => StubGatewaySettingsDto)
zarinpal?: StubGatewaySettingsDto;
@Type(() => ZarinpalGatewaySettingsDto)
zarinpal?: ZarinpalGatewaySettingsDto;
}
class EPaymentSettingsDto {
@@ -2,6 +2,7 @@ import type {
EPaymentSettings,
MellatGatewaySettings,
PaymentGatewayId,
ZarinpalGatewaySettings,
} from '../../business-settings/business-settings.types';
export type PaymentRedirect = {
@@ -46,6 +47,7 @@ export type VerifyPaymentResult = {
export type GatewayCredentials = {
mellat: MellatGatewaySettings;
zarinpal: ZarinpalGatewaySettings;
};
export interface PaymentGatewayAdapter {
+233
View File
@@ -0,0 +1,233 @@
import {
BadGatewayException,
Injectable,
Logger,
} from '@nestjs/common';
import type { EPaymentSettings } from '../../business-settings/business-settings.types';
import { isGatewayReady } from '../../business-settings/business-settings.util';
import type {
GatewayCredentials,
InitiatePaymentInput,
InitiatePaymentResult,
PaymentGatewayAdapter,
VerifyPaymentInput,
VerifyPaymentResult,
} from './payment-gateway.types';
const ZARINPAL_API_LIVE = 'https://payment.zarinpal.com/pg/v4/payment';
const ZARINPAL_API_SANDBOX = 'https://sandbox.zarinpal.com/pg/v4/payment';
const ZARINPAL_START_LIVE = 'https://www.zarinpal.com/pg/StartPay';
const ZARINPAL_START_SANDBOX = 'https://sandbox.zarinpal.com/pg/StartPay';
/** First successful verify. */
const ZARINPAL_OK = 100;
/** Already verified (idempotent retry). */
const ZARINPAL_ALREADY_VERIFIED = 101;
type ZarinpalEnvelope = {
data?: {
code?: number;
message?: string;
authority?: string;
ref_id?: number | string;
card_pan?: string;
card_hash?: string;
fee_type?: string;
fee?: number;
};
errors?:
| Array<{ code?: number; message?: string }>
| Record<string, unknown>
| [];
};
function readPayloadString(
payload: Record<string, unknown>,
...keys: string[]
): string {
for (const key of keys) {
const value = payload[key];
if (typeof value === 'string' && value.trim()) return value.trim();
if (typeof value === 'number' && Number.isFinite(value)) {
return String(value);
}
}
return '';
}
@Injectable()
export class ZarinpalGateway implements PaymentGatewayAdapter {
readonly id = 'zarinpal' as const;
private readonly logger = new Logger(ZarinpalGateway.name);
isConfigured(settings: EPaymentSettings): boolean {
return isGatewayReady(settings, 'zarinpal');
}
async initiate(
credentials: GatewayCredentials,
input: InitiatePaymentInput,
): Promise<InitiatePaymentResult> {
const zarinpal = credentials.zarinpal;
// ZarinPal amount is Rials; Meshkee stores IRT (Toman).
const amountRials = Math.round(input.amountIrt * 10);
const body = await this.apiCall(zarinpal.sandbox, 'request.json', {
merchant_id: zarinpal.merchantId,
amount: amountRials,
callback_url: input.callbackUrl,
description: (input.description ?? `Order ${input.orderId}`).slice(0, 255),
metadata: {
order_id: input.orderId.toString(),
transaction_id: input.transactionId.toString(),
},
});
const code = body.data?.code;
const authority = body.data?.authority?.trim() ?? '';
if (code !== ZARINPAL_OK || !authority) {
this.logger.warn(
`ZarinPal request failed business=${input.businessId} order=${input.orderId} code=${code ?? 'unknown'}`,
);
throw new BadGatewayException(
`ZarinPal payment init failed (code ${code ?? 'unknown'})`,
);
}
const startBase = zarinpal.sandbox
? ZARINPAL_START_SANDBOX
: ZARINPAL_START_LIVE;
return {
gatewayRef: authority,
redirect: {
method: 'GET',
url: `${startBase}/${authority}`,
},
meta: { code, amountRials, sandbox: zarinpal.sandbox },
};
}
async verify(
credentials: GatewayCredentials,
input: VerifyPaymentInput,
): Promise<VerifyPaymentResult> {
const zarinpal = credentials.zarinpal;
const status = readPayloadString(
input.callbackPayload,
'Status',
'status',
).toUpperCase();
const authority =
readPayloadString(
input.callbackPayload,
'Authority',
'authority',
) || (input.gatewayRef?.trim() ?? '');
if (status && status !== 'OK') {
return {
success: false,
resCode: status,
message: `ZarinPal payment declined (Status ${status})`,
meta: { status, authority },
};
}
if (!authority) {
return {
success: false,
resCode: 'missing_authority',
message: 'ZarinPal callback missing Authority',
meta: { status, authority },
};
}
const amountRials = Math.round(input.amountIrt * 10);
const body = await this.apiCall(zarinpal.sandbox, 'verify.json', {
merchant_id: zarinpal.merchantId,
amount: amountRials,
authority,
});
const code = body.data?.code;
const refId = body.data?.ref_id;
const trackId =
refId !== undefined && refId !== null ? String(refId) : undefined;
if (code !== ZARINPAL_OK && code !== ZARINPAL_ALREADY_VERIFIED) {
return {
success: false,
resCode: String(code ?? 'unknown'),
trackId,
message: `ZarinPal verify failed (code ${code ?? 'unknown'})`,
meta: {
status,
authority,
code,
errors: body.errors,
amountRials,
},
};
}
return {
success: true,
resCode: String(code),
trackId,
message: 'Payment verified',
meta: {
status,
authority,
code,
refId,
cardPan: body.data?.card_pan,
amountRials,
},
};
}
private async apiCall(
sandbox: boolean,
path: string,
payload: Record<string, unknown>,
): Promise<ZarinpalEnvelope> {
const base = sandbox ? ZARINPAL_API_SANDBOX : ZARINPAL_API_LIVE;
const url = `${base}/${path}`;
let response: Response;
try {
response = await fetch(url, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
});
} catch (err) {
this.logger.error(`ZarinPal network error (${path})`, err);
throw new BadGatewayException('Unable to reach ZarinPal payment gateway');
}
const text = await response.text();
let body: ZarinpalEnvelope;
try {
body = JSON.parse(text) as ZarinpalEnvelope;
} catch {
this.logger.error(
`ZarinPal non-JSON (${path}) HTTP ${response.status}: ${text.slice(0, 400)}`,
);
throw new BadGatewayException('ZarinPal payment gateway returned an error');
}
if (!response.ok && body.data?.code === undefined) {
this.logger.error(
`ZarinPal HTTP ${response.status} (${path}): ${text.slice(0, 400)}`,
);
throw new BadGatewayException('ZarinPal payment gateway returned an error');
}
return body;
}
}
+5 -2
View File
@@ -1,14 +1,17 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import type { PaymentGatewayId } from '../business-settings/business-settings.types';
import { MellatGateway } from './gateways/mellat.gateway';
import { ZarinpalGateway } from './gateways/zarinpal.gateway';
import type { PaymentGatewayAdapter } from './gateways/payment-gateway.types';
@Injectable()
export class PaymentGatewayRegistry {
private readonly adapters: Map<PaymentGatewayId, PaymentGatewayAdapter>;
constructor(mellat: MellatGateway) {
this.adapters = new Map([[mellat.id, mellat]]);
constructor(mellat: MellatGateway, zarinpal: ZarinpalGateway) {
this.adapters = new Map<PaymentGatewayId, PaymentGatewayAdapter>();
this.adapters.set(mellat.id, mellat);
this.adapters.set(zarinpal.id, zarinpal);
}
get(gatewayId: string): PaymentGatewayAdapter {
+7 -1
View File
@@ -1,6 +1,7 @@
import { Module } from '@nestjs/common';
import { BusinessSettingsModule } from '../business-settings/business-settings.module';
import { MellatGateway } from './gateways/mellat.gateway';
import { ZarinpalGateway } from './gateways/zarinpal.gateway';
import { PaymentGatewayRegistry } from './payment-gateway.registry';
import { PaymentsController } from './payments.controller';
import { PaymentsService } from './payments.service';
@@ -8,7 +9,12 @@ import { PaymentsService } from './payments.service';
@Module({
imports: [BusinessSettingsModule],
controllers: [PaymentsController],
providers: [MellatGateway, PaymentGatewayRegistry, PaymentsService],
providers: [
MellatGateway,
ZarinpalGateway,
PaymentGatewayRegistry,
PaymentsService,
],
exports: [PaymentsService],
})
export class PaymentsModule {}
+15 -3
View File
@@ -149,7 +149,10 @@ export class PaymentsService {
);
const result = await adapter.initiate(
{ mellat: ePayment.gateways.mellat },
{
mellat: ePayment.gateways.mellat,
zarinpal: ePayment.gateways.zarinpal,
},
{
businessId: input.businessId,
orderId: BigInt(input.order.id),
@@ -191,7 +194,13 @@ export class PaymentsService {
const adapter = this.gateways.get(gatewayType);
const saleOrderId = this.readString(payload, 'SaleOrderId', 'saleOrderId');
const refId = this.readString(payload, 'RefId', 'refId');
const refId = this.readString(
payload,
'RefId',
'refId',
'Authority',
'authority',
);
const transaction = await this.findCallbackTransaction(
businessId,
@@ -254,7 +263,10 @@ export class PaymentsService {
}
const verify = await adapter.verify(
{ mellat: settings.store.ePayment.gateways.mellat },
{
mellat: settings.store.ePayment.gateways.mellat,
zarinpal: settings.store.ePayment.gateways.zarinpal,
},
{
businessId,
orderId: transaction.orderId,
+88 -10
View File
@@ -105,17 +105,20 @@ export class StoreItemsService {
const pageSize = query.pageSize ?? 20;
const skip = (page - 1) * pageSize;
const where = await this.buildPublicVariantWhere(businessId, query);
const isSearch = Boolean(query.name?.trim());
const [items, total] = await Promise.all([
this.prisma.storeItemVariant.findMany({
where,
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'desc' }],
skip,
take: pageSize,
include: variantInclude,
}),
this.prisma.storeItemVariant.count({ where }),
]);
const [items, total] = isSearch
? await this.listPublicSearchPage(where, skip, pageSize)
: await Promise.all([
this.prisma.storeItemVariant.findMany({
where,
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'desc' }],
skip,
take: pageSize,
include: variantInclude,
}),
this.prisma.storeItemVariant.count({ where }),
]);
const productIds = [...new Set(items.map((item) => item.storeItem.productId))];
const [galleryByProduct, stockByProduct] = await Promise.all([
@@ -990,6 +993,81 @@ export class StoreItemsService {
return assignment?.categoryId ?? null;
}
private inStockWhere(
where: Prisma.StoreItemVariantWhereInput,
): Prisma.StoreItemVariantWhereInput {
return {
AND: [
where,
{ OR: [{ stockQuantity: null }, { stockQuantity: { gt: 0 } }] },
],
};
}
private outOfStockWhere(
where: Prisma.StoreItemVariantWhereInput,
): Prisma.StoreItemVariantWhereInput {
return { AND: [where, { stockQuantity: 0 }] };
}
/** Instant search: in-stock first (unlimited or qty > 0), then newest updatedAt. */
private async listPublicSearchPage(
where: Prisma.StoreItemVariantWhereInput,
skip: number,
pageSize: number,
): Promise<[VariantWithRelations[], number]> {
const inStockWhere = this.inStockWhere(where);
const outOfStockWhere = this.outOfStockWhere(where);
const orderBy: Prisma.StoreItemVariantOrderByWithRelationInput[] = [
{ updatedAt: 'desc' },
{ id: 'desc' },
];
const [inStockTotal, outOfStockTotal] = await Promise.all([
this.prisma.storeItemVariant.count({ where: inStockWhere }),
this.prisma.storeItemVariant.count({ where: outOfStockWhere }),
]);
const total = inStockTotal + outOfStockTotal;
if (total === 0 || pageSize <= 0) {
return [[], total];
}
if (skip >= inStockTotal) {
const items = await this.prisma.storeItemVariant.findMany({
where: outOfStockWhere,
orderBy,
skip: skip - inStockTotal,
take: pageSize,
include: variantInclude,
});
return [items, total];
}
const inStockTake = Math.min(pageSize, inStockTotal - skip);
const inStockItems = await this.prisma.storeItemVariant.findMany({
where: inStockWhere,
orderBy,
skip,
take: inStockTake,
include: variantInclude,
});
if (inStockItems.length >= pageSize) {
return [inStockItems, total];
}
const outOfStockItems = await this.prisma.storeItemVariant.findMany({
where: outOfStockWhere,
orderBy,
skip: 0,
take: pageSize - inStockItems.length,
include: variantInclude,
});
return [[...inStockItems, ...outOfStockItems], total];
}
private async buildPublicVariantWhere(
businessId: bigint,
query: ListPublicStoreItemsDto,
+57 -3
View File
@@ -35,6 +35,56 @@ type UserListRow = {
isActive: boolean;
};
/**
* ILIKE patterns so local Iranian input (09…) matches stored E.164 (+98…).
* `09024300` must match `+989024300340`; short fragments like `4300` still match.
*/
function cellNumberLikePatterns(input: string): string[] {
const trimmed = input.trim();
if (!trimmed) return [];
const patterns = new Set<string>();
patterns.add(`%${trimmed}%`);
const digits = trimmed.replace(/\D/g, '');
if (!digits) return [...patterns];
patterns.add(`%${digits}%`);
let rest = digits;
if (rest.startsWith('00')) rest = rest.slice(2);
if (rest.startsWith('98')) {
patterns.add(`%+${rest}%`);
const national = rest.slice(2);
if (national) {
patterns.add(`%0${national}%`);
patterns.add(`%${national}%`);
}
} else if (rest.startsWith('0')) {
const national = rest.slice(1);
if (national) {
patterns.add(`%+98${national}%`);
patterns.add(`%98${national}%`);
}
} else if (rest.startsWith('9')) {
patterns.add(`%+98${rest}%`);
patterns.add(`%98${rest}%`);
patterns.add(`%0${rest}%`);
}
return [...patterns];
}
function cellNumberIlikeFilter(input: string | undefined): Prisma.Sql {
const patterns = cellNumberLikePatterns(input ?? '');
if (!patterns.length) return Prisma.empty;
return Prisma.sql`AND (${Prisma.join(
patterns.map((pattern) => Prisma.sql`u.cell_number ILIKE ${pattern}`),
' OR ',
)})`;
}
@Injectable()
export class UsersService {
constructor(
@@ -111,7 +161,6 @@ export class UsersService {
const skip = (page - 1) * pageSize;
const nameLike = query.name?.trim() ? `%${query.name.trim()}%` : null;
const cellLike = query.cellNumber?.trim() ? `%${query.cellNumber.trim()}%` : null;
const roleSlug = query.role?.trim() || null;
const businessId = query.businessId ?? null;
const membership = query.membership ?? null;
@@ -125,7 +174,7 @@ export class UsersService {
OR (COALESCE(u.first_name, '') || ' ' || COALESCE(u.last_name, '')) ILIKE ${nameLike}
)
` : Prisma.empty}
${cellLike ? Prisma.sql`AND u.cell_number ILIKE ${cellLike}` : Prisma.empty}
${cellNumberIlikeFilter(query.cellNumber)}
${roleSlug ? Prisma.sql`
AND EXISTS (
SELECT 1
@@ -346,6 +395,11 @@ export class UsersService {
const q = query.q.trim();
const limit = Math.min(Math.max(query.limit ?? 20, 1), 50);
const like = `%${q}%`;
const cellPatterns = cellNumberLikePatterns(q);
const cellMatch = Prisma.join(
cellPatterns.map((pattern) => Prisma.sql`u.cell_number ILIKE ${pattern}`),
' OR ',
);
const items = await this.prisma.$queryRaw<
{
@@ -365,7 +419,7 @@ export class UsersService {
FROM users u
WHERE u.is_active = TRUE
AND (
u.cell_number ILIKE ${like}
${cellMatch}
OR u.first_name ILIKE ${like}
OR u.last_name ILIKE ${like}
OR u.email ILIKE ${like}
+2 -2
View File
@@ -31,10 +31,10 @@ You are building a **Meshkee business website (storefront)**. You must use the M
### Typical bootstrap sequence
1. `GET /tenants/{domain}` → branding + `businessId`
2. Homepage: business-info, sliders, category-groups, brand-groups, store-specials
3. Catalog: categories, products, store-items, **user-products** (customer stock listings)
3. Catalog: categories, products, store-items (`name` instant search: in-stock first, then `updatedAt`), **user-products** (customer stock listings)
4. Auth: register/login → store tokens. Optional: `POST /auth/send-otp` then `POST /auth/login-otp` (passwordless) or `POST /auth/reset-password` (forgot password). `POST /auth/verify-otp` only marks the cell verified (no tokens).
5. Cart checkout with `addressId` or inline `shippingAddress` + `payment`
- For online pay: `payment.type = "e_payment_gate"`, `gatewayType` (e.g. `"mellat"`), and absolute `returnUrl`
- For online pay: `payment.type = "e_payment_gate"`, `gatewayType` (e.g. `"mellat"` or `"zarinpal"`), and absolute `returnUrl`
- Response includes `payment.redirect` `{ method, url, fields }` — POST/redirect shopper to the bank
- Bank callback hits API then redirects to `returnUrl?status=success|failed&orderId=…`
- Enabled gateways: `GET /tenants/{domain}` → `ePayment`, or `GET /businesses/{businessId}/payments/methods`
@@ -1716,6 +1716,29 @@
"url": "{{baseUrl}}/businesses/{{businessId}}/payments/mellat/callback",
"description": "Called by Mellat (not by the website). Verifies+settles, then 303 redirect to returnUrl."
}
},
{
"name": "ZarinPal callback (gateway → API)",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{baseUrl}}/businesses/{{businessId}}/payments/zarinpal/callback?Authority={{authority}}&Status=OK",
"host": ["{{baseUrl}}"],
"path": [
"businesses",
"{{businessId}}",
"payments",
"zarinpal",
"callback"
],
"query": [
{ "key": "Authority", "value": "{{authority}}" },
{ "key": "Status", "value": "OK" }
]
},
"description": "Called by ZarinPal (not by the website). Verifies payment, then 303 redirect to returnUrl."
}
}
]
},
+1
View File
@@ -645,6 +645,7 @@
{
"name": "name",
"in": "query",
"description": "Instant search on product title / nameFa. Results are ordered in-stock first (unlimited or qty > 0), then by updatedAt desc.",
"schema": {
"type": "string"
}