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 |
|
| Method | Path | Description |
|
||||||
|--------|------|-------------|
|
|--------|------|-------------|
|
||||||
| GET | `/payments/methods` | Enabled gateways for this business (no secrets). Also on `GET /tenants/:host` as `ePayment` |
|
| 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
|
#### Orders
|
||||||
|
|
||||||
@@ -619,7 +619,7 @@ See `.env.example` for the full list. Key groups:
|
|||||||
- Product variation values (which options a product offers)
|
- Product variation values (which options a product offers)
|
||||||
- Store items / product variants (price, stock, SKU)
|
- Store items / product variants (price, stock, SKU)
|
||||||
- Shopping cart + checkout + orders (customer + admin)
|
- 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
|
- Product technical info
|
||||||
- Category variations & technical forms
|
- Category variations & technical forms
|
||||||
- Tenant resolution by domain
|
- Tenant resolution by domain
|
||||||
|
|||||||
@@ -31,10 +31,10 @@ You are building a **Meshkee business website (storefront)**. You must use the M
|
|||||||
### Typical bootstrap sequence
|
### Typical bootstrap sequence
|
||||||
1. `GET /tenants/{domain}` → branding + `businessId`
|
1. `GET /tenants/{domain}` → branding + `businessId`
|
||||||
2. Homepage: business-info, sliders, category-groups, brand-groups, store-specials
|
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).
|
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`
|
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
|
- 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=…`
|
- Bank callback hits API then redirects to `returnUrl?status=success|failed&orderId=…`
|
||||||
- Enabled gateways: `GET /tenants/{domain}` → `ePayment`, or `GET /businesses/{businessId}/payments/methods`
|
- Enabled gateways: `GET /tenants/{domain}` → `ePayment`, or `GET /businesses/{businessId}/payments/methods`
|
||||||
|
|||||||
@@ -1716,6 +1716,29 @@
|
|||||||
"url": "{{baseUrl}}/businesses/{{businessId}}/payments/mellat/callback",
|
"url": "{{baseUrl}}/businesses/{{businessId}}/payments/mellat/callback",
|
||||||
"description": "Called by Mellat (not by the website). Verifies+settles, then 303 redirect to returnUrl."
|
"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",
|
"name": "name",
|
||||||
"in": "query",
|
"in": "query",
|
||||||
|
"description": "Instant search on product title / nameFa. Results are ordered in-stock first (unlimited or qty > 0), then by updatedAt desc.",
|
||||||
"schema": {
|
"schema": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -89,7 +89,10 @@ if [[ -f .next/standalone/server.js ]]; then
|
|||||||
cp -a .next/static .next/standalone/.next/static
|
cp -a .next/static .next/standalone/.next/static
|
||||||
if [[ -d public ]]; then
|
if [[ -d public ]]; then
|
||||||
rm -rf .next/standalone/public
|
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
|
fi
|
||||||
|
|
||||||
SLUG="$SLUG" ROOT="$ROOT" ECOSYSTEM="$ECOSYSTEM" node <<'NODE'
|
SLUG="$SLUG" ROOT="$ROOT" ECOSYSTEM="$ECOSYSTEM" node <<'NODE'
|
||||||
@@ -121,6 +124,7 @@ app.cwd = root + '/.next/standalone';
|
|||||||
app.script = 'server.js';
|
app.script = 'server.js';
|
||||||
app.args = '';
|
app.args = '';
|
||||||
app.env = {
|
app.env = {
|
||||||
|
...(app.env || {}),
|
||||||
NODE_ENV: 'production',
|
NODE_ENV: 'production',
|
||||||
PORT: String(port),
|
PORT: String(port),
|
||||||
HOSTNAME: '127.0.0.1',
|
HOSTNAME: '127.0.0.1',
|
||||||
@@ -136,15 +140,16 @@ cfg.apps.forEach((entry, idx) => {
|
|||||||
lines.push(` args: ${JSON.stringify(entry.args)},`);
|
lines.push(` args: ${JSON.stringify(entry.args)},`);
|
||||||
}
|
}
|
||||||
lines.push(' env: {');
|
lines.push(' env: {');
|
||||||
lines.push(` NODE_ENV: ${JSON.stringify(entry.env.NODE_ENV)},`);
|
for (const [key, value] of Object.entries(entry.env || {})) {
|
||||||
lines.push(` PORT: ${JSON.stringify(entry.env.PORT)},`);
|
lines.push(` ${key}: ${JSON.stringify(String(value))},`);
|
||||||
if (entry.env.HOSTNAME) {
|
|
||||||
lines.push(` HOSTNAME: ${JSON.stringify(entry.env.HOSTNAME)},`);
|
|
||||||
}
|
}
|
||||||
lines.push(' },');
|
lines.push(' },');
|
||||||
lines.push(` error_file: ${JSON.stringify(entry.error_file)},`);
|
lines.push(` error_file: ${JSON.stringify(entry.error_file)},`);
|
||||||
lines.push(` out_file: ${JSON.stringify(entry.out_file)},`);
|
lines.push(` out_file: ${JSON.stringify(entry.out_file)},`);
|
||||||
lines.push(' time: true,');
|
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(idx === cfg.apps.length - 1 ? ' }' : ' },');
|
||||||
});
|
});
|
||||||
lines.push(' ],');
|
lines.push(' ],');
|
||||||
|
|||||||
@@ -148,6 +148,12 @@ export class BusinessSettingsService {
|
|||||||
enabled:
|
enabled:
|
||||||
dto.store.ePayment.gateways?.zarinpal?.enabled ??
|
dto.store.ePayment.gateways?.zarinpal?.enabled ??
|
||||||
currentEPayment.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;
|
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.
|
* Credential fields are added when each adapter ships.
|
||||||
*/
|
*/
|
||||||
export type StubGatewaySettings = {
|
export type StubGatewaySettings = {
|
||||||
@@ -71,7 +80,7 @@ export type PaymentGatewaysSettings = {
|
|||||||
sep: StubGatewaySettings;
|
sep: StubGatewaySettings;
|
||||||
snappay: StubGatewaySettings;
|
snappay: StubGatewaySettings;
|
||||||
digipay: StubGatewaySettings;
|
digipay: StubGatewaySettings;
|
||||||
zarinpal: StubGatewaySettings;
|
zarinpal: ZarinpalGatewaySettings;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Online e-payment (website checkout). */
|
/** Online e-payment (website checkout). */
|
||||||
@@ -192,6 +201,12 @@ export const DEFAULT_STUB_GATEWAY_SETTINGS: StubGatewaySettings = {
|
|||||||
enabled: false,
|
enabled: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const DEFAULT_ZARINPAL_GATEWAY_SETTINGS: ZarinpalGatewaySettings = {
|
||||||
|
enabled: false,
|
||||||
|
merchantId: '',
|
||||||
|
sandbox: false,
|
||||||
|
};
|
||||||
|
|
||||||
export const DEFAULT_EPAYMENT_SETTINGS: EPaymentSettings = {
|
export const DEFAULT_EPAYMENT_SETTINGS: EPaymentSettings = {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
defaultGateway: null,
|
defaultGateway: null,
|
||||||
@@ -200,7 +215,7 @@ export const DEFAULT_EPAYMENT_SETTINGS: EPaymentSettings = {
|
|||||||
sep: { ...DEFAULT_STUB_GATEWAY_SETTINGS },
|
sep: { ...DEFAULT_STUB_GATEWAY_SETTINGS },
|
||||||
snappay: { ...DEFAULT_STUB_GATEWAY_SETTINGS },
|
snappay: { ...DEFAULT_STUB_GATEWAY_SETTINGS },
|
||||||
digipay: { ...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_MELLAT_GATEWAY_SETTINGS,
|
||||||
DEFAULT_ORDER_PROCESS_STEPS,
|
DEFAULT_ORDER_PROCESS_STEPS,
|
||||||
DEFAULT_STUB_GATEWAY_SETTINGS,
|
DEFAULT_STUB_GATEWAY_SETTINGS,
|
||||||
|
DEFAULT_ZARINPAL_GATEWAY_SETTINGS,
|
||||||
EPaymentSettings,
|
EPaymentSettings,
|
||||||
HOME_CHART_IDS,
|
HOME_CHART_IDS,
|
||||||
MellatGatewaySettings,
|
MellatGatewaySettings,
|
||||||
@@ -25,6 +26,7 @@ import {
|
|||||||
type PaymentGatewayId,
|
type PaymentGatewayId,
|
||||||
OrderProcessStep,
|
OrderProcessStep,
|
||||||
StubGatewaySettings,
|
StubGatewaySettings,
|
||||||
|
ZarinpalGatewaySettings,
|
||||||
} from './business-settings.types';
|
} from './business-settings.types';
|
||||||
import {
|
import {
|
||||||
defaultOrderStepColor,
|
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 {
|
export function normalizeEPaymentSettings(value: unknown): EPaymentSettings {
|
||||||
const source = isRecord(value) ? value : {};
|
const source = isRecord(value) ? value : {};
|
||||||
const gateways = isRecord(source.gateways) ? source.gateways : {};
|
const gateways = isRecord(source.gateways) ? source.gateways : {};
|
||||||
@@ -146,7 +166,7 @@ export function normalizeEPaymentSettings(value: unknown): EPaymentSettings {
|
|||||||
sep: readStubGateway(gateways.sep),
|
sep: readStubGateway(gateways.sep),
|
||||||
snappay: readStubGateway(gateways.snappay),
|
snappay: readStubGateway(gateways.snappay),
|
||||||
digipay: readStubGateway(gateways.digipay),
|
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 },
|
sep: { enabled: patch.gateways.sep.enabled },
|
||||||
snappay: { enabled: patch.gateways.snappay.enabled },
|
snappay: { enabled: patch.gateways.snappay.enabled },
|
||||||
digipay: { enabled: patch.gateways.digipay.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
|
g.password.length > 0
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
case 'zarinpal': {
|
||||||
|
const g = settings.gateways.zarinpal;
|
||||||
|
return g.enabled && g.merchantId.length > 0;
|
||||||
|
}
|
||||||
case 'sep':
|
case 'sep':
|
||||||
case 'snappay':
|
case 'snappay':
|
||||||
case 'digipay':
|
case 'digipay':
|
||||||
case 'zarinpal':
|
|
||||||
return false;
|
return false;
|
||||||
default:
|
default:
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -104,6 +104,20 @@ class StubGatewaySettingsDto {
|
|||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class ZarinpalGatewaySettingsDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
enabled?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
merchantId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
sandbox?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
class PaymentGatewaysSettingsDto {
|
class PaymentGatewaysSettingsDto {
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@ValidateNested()
|
@ValidateNested()
|
||||||
@@ -127,8 +141,8 @@ class PaymentGatewaysSettingsDto {
|
|||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@ValidateNested()
|
@ValidateNested()
|
||||||
@Type(() => StubGatewaySettingsDto)
|
@Type(() => ZarinpalGatewaySettingsDto)
|
||||||
zarinpal?: StubGatewaySettingsDto;
|
zarinpal?: ZarinpalGatewaySettingsDto;
|
||||||
}
|
}
|
||||||
|
|
||||||
class EPaymentSettingsDto {
|
class EPaymentSettingsDto {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type {
|
|||||||
EPaymentSettings,
|
EPaymentSettings,
|
||||||
MellatGatewaySettings,
|
MellatGatewaySettings,
|
||||||
PaymentGatewayId,
|
PaymentGatewayId,
|
||||||
|
ZarinpalGatewaySettings,
|
||||||
} from '../../business-settings/business-settings.types';
|
} from '../../business-settings/business-settings.types';
|
||||||
|
|
||||||
export type PaymentRedirect = {
|
export type PaymentRedirect = {
|
||||||
@@ -46,6 +47,7 @@ export type VerifyPaymentResult = {
|
|||||||
|
|
||||||
export type GatewayCredentials = {
|
export type GatewayCredentials = {
|
||||||
mellat: MellatGatewaySettings;
|
mellat: MellatGatewaySettings;
|
||||||
|
zarinpal: ZarinpalGatewaySettings;
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface PaymentGatewayAdapter {
|
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 { BadRequestException, Injectable } from '@nestjs/common';
|
||||||
import type { PaymentGatewayId } from '../business-settings/business-settings.types';
|
import type { PaymentGatewayId } from '../business-settings/business-settings.types';
|
||||||
import { MellatGateway } from './gateways/mellat.gateway';
|
import { MellatGateway } from './gateways/mellat.gateway';
|
||||||
|
import { ZarinpalGateway } from './gateways/zarinpal.gateway';
|
||||||
import type { PaymentGatewayAdapter } from './gateways/payment-gateway.types';
|
import type { PaymentGatewayAdapter } from './gateways/payment-gateway.types';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PaymentGatewayRegistry {
|
export class PaymentGatewayRegistry {
|
||||||
private readonly adapters: Map<PaymentGatewayId, PaymentGatewayAdapter>;
|
private readonly adapters: Map<PaymentGatewayId, PaymentGatewayAdapter>;
|
||||||
|
|
||||||
constructor(mellat: MellatGateway) {
|
constructor(mellat: MellatGateway, zarinpal: ZarinpalGateway) {
|
||||||
this.adapters = new Map([[mellat.id, mellat]]);
|
this.adapters = new Map<PaymentGatewayId, PaymentGatewayAdapter>();
|
||||||
|
this.adapters.set(mellat.id, mellat);
|
||||||
|
this.adapters.set(zarinpal.id, zarinpal);
|
||||||
}
|
}
|
||||||
|
|
||||||
get(gatewayId: string): PaymentGatewayAdapter {
|
get(gatewayId: string): PaymentGatewayAdapter {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { BusinessSettingsModule } from '../business-settings/business-settings.module';
|
import { BusinessSettingsModule } from '../business-settings/business-settings.module';
|
||||||
import { MellatGateway } from './gateways/mellat.gateway';
|
import { MellatGateway } from './gateways/mellat.gateway';
|
||||||
|
import { ZarinpalGateway } from './gateways/zarinpal.gateway';
|
||||||
import { PaymentGatewayRegistry } from './payment-gateway.registry';
|
import { PaymentGatewayRegistry } from './payment-gateway.registry';
|
||||||
import { PaymentsController } from './payments.controller';
|
import { PaymentsController } from './payments.controller';
|
||||||
import { PaymentsService } from './payments.service';
|
import { PaymentsService } from './payments.service';
|
||||||
@@ -8,7 +9,12 @@ import { PaymentsService } from './payments.service';
|
|||||||
@Module({
|
@Module({
|
||||||
imports: [BusinessSettingsModule],
|
imports: [BusinessSettingsModule],
|
||||||
controllers: [PaymentsController],
|
controllers: [PaymentsController],
|
||||||
providers: [MellatGateway, PaymentGatewayRegistry, PaymentsService],
|
providers: [
|
||||||
|
MellatGateway,
|
||||||
|
ZarinpalGateway,
|
||||||
|
PaymentGatewayRegistry,
|
||||||
|
PaymentsService,
|
||||||
|
],
|
||||||
exports: [PaymentsService],
|
exports: [PaymentsService],
|
||||||
})
|
})
|
||||||
export class PaymentsModule {}
|
export class PaymentsModule {}
|
||||||
|
|||||||
@@ -149,7 +149,10 @@ export class PaymentsService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const result = await adapter.initiate(
|
const result = await adapter.initiate(
|
||||||
{ mellat: ePayment.gateways.mellat },
|
{
|
||||||
|
mellat: ePayment.gateways.mellat,
|
||||||
|
zarinpal: ePayment.gateways.zarinpal,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
businessId: input.businessId,
|
businessId: input.businessId,
|
||||||
orderId: BigInt(input.order.id),
|
orderId: BigInt(input.order.id),
|
||||||
@@ -191,7 +194,13 @@ export class PaymentsService {
|
|||||||
const adapter = this.gateways.get(gatewayType);
|
const adapter = this.gateways.get(gatewayType);
|
||||||
|
|
||||||
const saleOrderId = this.readString(payload, 'SaleOrderId', 'saleOrderId');
|
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(
|
const transaction = await this.findCallbackTransaction(
|
||||||
businessId,
|
businessId,
|
||||||
@@ -254,7 +263,10 @@ export class PaymentsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const verify = await adapter.verify(
|
const verify = await adapter.verify(
|
||||||
{ mellat: settings.store.ePayment.gateways.mellat },
|
{
|
||||||
|
mellat: settings.store.ePayment.gateways.mellat,
|
||||||
|
zarinpal: settings.store.ePayment.gateways.zarinpal,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
businessId,
|
businessId,
|
||||||
orderId: transaction.orderId,
|
orderId: transaction.orderId,
|
||||||
|
|||||||
@@ -105,8 +105,11 @@ export class StoreItemsService {
|
|||||||
const pageSize = query.pageSize ?? 20;
|
const pageSize = query.pageSize ?? 20;
|
||||||
const skip = (page - 1) * pageSize;
|
const skip = (page - 1) * pageSize;
|
||||||
const where = await this.buildPublicVariantWhere(businessId, query);
|
const where = await this.buildPublicVariantWhere(businessId, query);
|
||||||
|
const isSearch = Boolean(query.name?.trim());
|
||||||
|
|
||||||
const [items, total] = await Promise.all([
|
const [items, total] = isSearch
|
||||||
|
? await this.listPublicSearchPage(where, skip, pageSize)
|
||||||
|
: await Promise.all([
|
||||||
this.prisma.storeItemVariant.findMany({
|
this.prisma.storeItemVariant.findMany({
|
||||||
where,
|
where,
|
||||||
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'desc' }],
|
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'desc' }],
|
||||||
@@ -990,6 +993,81 @@ export class StoreItemsService {
|
|||||||
return assignment?.categoryId ?? null;
|
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(
|
private async buildPublicVariantWhere(
|
||||||
businessId: bigint,
|
businessId: bigint,
|
||||||
query: ListPublicStoreItemsDto,
|
query: ListPublicStoreItemsDto,
|
||||||
|
|||||||
@@ -35,6 +35,56 @@ type UserListRow = {
|
|||||||
isActive: boolean;
|
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()
|
@Injectable()
|
||||||
export class UsersService {
|
export class UsersService {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -111,7 +161,6 @@ export class UsersService {
|
|||||||
const skip = (page - 1) * pageSize;
|
const skip = (page - 1) * pageSize;
|
||||||
|
|
||||||
const nameLike = query.name?.trim() ? `%${query.name.trim()}%` : null;
|
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 roleSlug = query.role?.trim() || null;
|
||||||
const businessId = query.businessId ?? null;
|
const businessId = query.businessId ?? null;
|
||||||
const membership = query.membership ?? null;
|
const membership = query.membership ?? null;
|
||||||
@@ -125,7 +174,7 @@ export class UsersService {
|
|||||||
OR (COALESCE(u.first_name, '') || ' ' || COALESCE(u.last_name, '')) ILIKE ${nameLike}
|
OR (COALESCE(u.first_name, '') || ' ' || COALESCE(u.last_name, '')) ILIKE ${nameLike}
|
||||||
)
|
)
|
||||||
` : Prisma.empty}
|
` : Prisma.empty}
|
||||||
${cellLike ? Prisma.sql`AND u.cell_number ILIKE ${cellLike}` : Prisma.empty}
|
${cellNumberIlikeFilter(query.cellNumber)}
|
||||||
${roleSlug ? Prisma.sql`
|
${roleSlug ? Prisma.sql`
|
||||||
AND EXISTS (
|
AND EXISTS (
|
||||||
SELECT 1
|
SELECT 1
|
||||||
@@ -346,6 +395,11 @@ export class UsersService {
|
|||||||
const q = query.q.trim();
|
const q = query.q.trim();
|
||||||
const limit = Math.min(Math.max(query.limit ?? 20, 1), 50);
|
const limit = Math.min(Math.max(query.limit ?? 20, 1), 50);
|
||||||
const like = `%${q}%`;
|
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<
|
const items = await this.prisma.$queryRaw<
|
||||||
{
|
{
|
||||||
@@ -365,7 +419,7 @@ export class UsersService {
|
|||||||
FROM users u
|
FROM users u
|
||||||
WHERE u.is_active = TRUE
|
WHERE u.is_active = TRUE
|
||||||
AND (
|
AND (
|
||||||
u.cell_number ILIKE ${like}
|
${cellMatch}
|
||||||
OR u.first_name ILIKE ${like}
|
OR u.first_name ILIKE ${like}
|
||||||
OR u.last_name ILIKE ${like}
|
OR u.last_name ILIKE ${like}
|
||||||
OR u.email 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
|
### Typical bootstrap sequence
|
||||||
1. `GET /tenants/{domain}` → branding + `businessId`
|
1. `GET /tenants/{domain}` → branding + `businessId`
|
||||||
2. Homepage: business-info, sliders, category-groups, brand-groups, store-specials
|
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).
|
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`
|
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
|
- 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=…`
|
- Bank callback hits API then redirects to `returnUrl?status=success|failed&orderId=…`
|
||||||
- Enabled gateways: `GET /tenants/{domain}` → `ePayment`, or `GET /businesses/{businessId}/payments/methods`
|
- Enabled gateways: `GET /tenants/{domain}` → `ePayment`, or `GET /businesses/{businessId}/payments/methods`
|
||||||
|
|||||||
@@ -1716,6 +1716,29 @@
|
|||||||
"url": "{{baseUrl}}/businesses/{{businessId}}/payments/mellat/callback",
|
"url": "{{baseUrl}}/businesses/{{businessId}}/payments/mellat/callback",
|
||||||
"description": "Called by Mellat (not by the website). Verifies+settles, then 303 redirect to returnUrl."
|
"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",
|
"name": "name",
|
||||||
"in": "query",
|
"in": "query",
|
||||||
|
"description": "Instant search on product title / nameFa. Results are ordered in-stock first (unlimited or qty > 0), then by updatedAt desc.",
|
||||||
"schema": {
|
"schema": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user