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:
co-authored by
Cursor
parent
c76c3967bb
commit
b991a60cbc
@@ -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
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user