feat: accounting integration (D27), SMS relay driver (D26), rebuild user apps
- Add Tahesab/Zarrin accounting sync module with admin panel UI - Add SMS relay driver as default provider, refactor SMS into driver pattern - Update customer services, orders, products, and catalog queries - Rebuild user-app and user-app-v2 dist assets - Add Prisma migration for accounting tables - Update docker-compose and env examples - Update parity docs (DECISIONS, ISSUES, PROGRESS) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
a72fa607a5
commit
19d490e790
@@ -0,0 +1,17 @@
|
||||
import { relayDriver } from './relay.driver';
|
||||
import { SmsDriver } from './sms-driver';
|
||||
import { trezDriver } from './trez.driver';
|
||||
|
||||
/**
|
||||
* Every SMS gateway, keyed by its `SMS_PROVIDER` value. A new provider is one
|
||||
* driver file plus one line here.
|
||||
*/
|
||||
export const SMS_DRIVERS: Readonly<Record<string, SmsDriver>> = {
|
||||
[relayDriver.name]: relayDriver,
|
||||
[trezDriver.name]: trezDriver,
|
||||
};
|
||||
|
||||
/** Used when `SMS_PROVIDER` is unset or blank (D26). */
|
||||
export const DEFAULT_SMS_PROVIDER = relayDriver.name;
|
||||
|
||||
export type { SmsDriver, SmsSendResult } from './sms-driver';
|
||||
@@ -0,0 +1,110 @@
|
||||
import { networkInterfaces } from 'node:os';
|
||||
import { envOr, envOrNull } from '../../../common/env';
|
||||
import { SmsDriver, postWithTimeout } from './sms-driver';
|
||||
|
||||
/**
|
||||
* The owner's SMS gateway (`/api/createsms`) — a relay that serves the SMS of
|
||||
* several panels and apps and forwards to the actual provider. The default
|
||||
* driver since D26.
|
||||
*
|
||||
* Shape, from the owner's PHP client:
|
||||
* * POST JSON, 15s timeout, auth header `secret_key`
|
||||
* * body: { phone, host, text, platform }
|
||||
* * `host` identifies the sending server: a configured value, else the
|
||||
* server's own IP (`gethostbyname(gethostname())`)
|
||||
* * `platform: "kave"` routes through Kavenegar; null lets the gateway pick.
|
||||
* The owner wants every message through Kavenegar.
|
||||
*
|
||||
* The secret is in source on the owner's decision (D26): the gateway has a
|
||||
* second verification factor held elsewhere. `SMS_RELAY_SECRET` overrides it.
|
||||
*
|
||||
* The PHP client forced IPv4 (CURLOPT_IPRESOLVE); the URL is an IP literal, so
|
||||
* there is nothing to resolve and fetch needs no equivalent.
|
||||
*/
|
||||
|
||||
const DEFAULT_RELAY_URL = 'http://185.231.112.101/api/createsms';
|
||||
const DEFAULT_RELAY_SECRET = 'f21debfb-83ac-4624-a2f4-f08375043181';
|
||||
const DEFAULT_RELAY_PLATFORM = 'kave';
|
||||
|
||||
/** `Http::timeout(15)` in the owner's client. */
|
||||
const REQUEST_TIMEOUT_MS = 15_000;
|
||||
|
||||
/** The first external IPv4 of this machine — Node's `gethostbyname(gethostname())`. */
|
||||
export function serverIpv4(): string | null {
|
||||
for (const addresses of Object.values(networkInterfaces())) {
|
||||
for (const a of addresses ?? []) {
|
||||
if (a.family === 'IPv4' && !a.internal) return a.address;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function relayConfig() {
|
||||
const platform = envOr('SMS_RELAY_PLATFORM', DEFAULT_RELAY_PLATFORM);
|
||||
return {
|
||||
url: envOr('SMS_RELAY_URL', DEFAULT_RELAY_URL),
|
||||
secret: envOr('SMS_RELAY_SECRET', DEFAULT_RELAY_SECRET),
|
||||
host: envOrNull('SMS_RELAY_HOST') ?? serverIpv4(),
|
||||
// "default" sends null, so the gateway chooses the line itself.
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The gateway's reply format is not documented, so a 2xx is trusted unless the
|
||||
* body plainly says otherwise. Returns the reason, or null when the reply
|
||||
* reads as accepted.
|
||||
*
|
||||
* A non-empty body that is not JSON counts as a failure: the gateway is a JSON
|
||||
* API, and its own PHP errors arrive as bare text with HTTP 200 — seen live on
|
||||
* 2026-10-05 as "cURL library is not loaded", with nothing delivered.
|
||||
*/
|
||||
export function relayRejection(body: string): string | null {
|
||||
const text = body.trim();
|
||||
if (text === '') return null;
|
||||
|
||||
let data: unknown;
|
||||
try {
|
||||
data = JSON.parse(text);
|
||||
} catch {
|
||||
return text.slice(0, 200);
|
||||
}
|
||||
if (!data || typeof data !== 'object' || Array.isArray(data)) return null;
|
||||
|
||||
const d = data as Record<string, unknown>;
|
||||
const failedStatus =
|
||||
d.status === false ||
|
||||
(typeof d.status === 'string' && /^(error|fail|failed|failure)$/i.test(d.status));
|
||||
const failed = failedStatus || d.success === false || d.ok === false || Boolean(d.error);
|
||||
if (!failed) return null;
|
||||
|
||||
if (typeof d.error === 'string' && d.error) return d.error;
|
||||
if (typeof d.message === 'string' && d.message) return d.message;
|
||||
return 'rejected-by-gateway';
|
||||
}
|
||||
|
||||
export const relayDriver: SmsDriver = {
|
||||
name: 'relay',
|
||||
|
||||
missingConfig() {
|
||||
// The URL and secret always have defaults; only the host can be missing,
|
||||
// on a machine with no external IPv4.
|
||||
return relayConfig().host ? [] : ['SMS_RELAY_HOST'];
|
||||
},
|
||||
|
||||
async deliver(mobile, text) {
|
||||
const c = relayConfig();
|
||||
const reply = await postWithTimeout(
|
||||
c.url,
|
||||
{ 'Content-Type': 'application/json', secret_key: c.secret },
|
||||
JSON.stringify({ phone: mobile, host: c.host, text }),
|
||||
REQUEST_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
if (!reply.ok) return { ok: false, response: reply.body, error: `http-${reply.status}` };
|
||||
|
||||
const rejection = relayRejection(reply.body);
|
||||
if (rejection) return { ok: false, response: reply.body, error: rejection };
|
||||
|
||||
return { ok: true, response: reply.body };
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* One SMS gateway. SmsService picks a driver by `SMS_PROVIDER` and owns
|
||||
* everything around the call — trimming, persistence, logging and the
|
||||
* never-throw guarantee — so a driver only has to talk to its provider.
|
||||
*/
|
||||
|
||||
export interface SmsSendResult {
|
||||
ok: boolean;
|
||||
/** Provider response body, when one was received. */
|
||||
response?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface SmsDriver {
|
||||
/** The `SMS_PROVIDER` value that selects this driver. */
|
||||
readonly name: string;
|
||||
|
||||
/** Names of the env variables this driver still needs; empty when it can send. */
|
||||
missingConfig(): string[];
|
||||
|
||||
/**
|
||||
* Sends one already-trimmed message. May throw on a network error or
|
||||
* timeout — SmsService turns that into `{ ok: false }`.
|
||||
*/
|
||||
deliver(mobile: string, text: string): Promise<SmsSendResult>;
|
||||
}
|
||||
|
||||
export interface HttpReply {
|
||||
ok: boolean;
|
||||
status: number;
|
||||
body: string;
|
||||
}
|
||||
|
||||
/** POST with an abort-based timeout. Throws on network failure or timeout. */
|
||||
export async function postWithTimeout(
|
||||
url: string,
|
||||
headers: Record<string, string>,
|
||||
body: string,
|
||||
timeoutMs: number,
|
||||
): Promise<HttpReply> {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body,
|
||||
signal: controller.signal,
|
||||
});
|
||||
return { ok: response.ok, status: response.status, body: await response.text() };
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { envOr } from '../../../common/env';
|
||||
import { SmsDriver, postWithTimeout } from './sms-driver';
|
||||
|
||||
/**
|
||||
* Trez panel — port of henza_server/app/Models/Sms.php.
|
||||
*
|
||||
* Reference behaviour (`Sms::Send`, `Sms.php:57-82`):
|
||||
* * POST, form-urlencoded, 20s timeout
|
||||
* * fields: PhoneNumber (sender line), Username, Password, Mobile, Message
|
||||
*
|
||||
* Credentials come from the environment. The reference hardcodes live
|
||||
* production credentials in the source (SEC-12); they are not copied here.
|
||||
*
|
||||
* No longer the default since D26, kept selectable with `SMS_PROVIDER=trez`.
|
||||
*/
|
||||
|
||||
/** Reference endpoint (`Sms.php:69`). Overridable for staging. */
|
||||
export const DEFAULT_TREZ_URL = 'https://smspanel.trez.ir/SendMessageWithCode.ashx';
|
||||
|
||||
/** `Http::timeout(20)` in the reference (`Sms.php:72`). */
|
||||
const REQUEST_TIMEOUT_MS = 20_000;
|
||||
|
||||
export function trezConfig() {
|
||||
return {
|
||||
url: envOr('SMS_TREZ_URL', DEFAULT_TREZ_URL),
|
||||
username: process.env.SMS_TREZ_USERNAME ?? '',
|
||||
password: process.env.SMS_TREZ_PASSWORD ?? '',
|
||||
senderLine: process.env.SMS_TREZ_SENDER_LINE ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
export const trezDriver: SmsDriver = {
|
||||
name: 'trez',
|
||||
|
||||
missingConfig() {
|
||||
const c = trezConfig();
|
||||
return (
|
||||
[
|
||||
['SMS_TREZ_USERNAME', c.username],
|
||||
['SMS_TREZ_PASSWORD', c.password],
|
||||
['SMS_TREZ_SENDER_LINE', c.senderLine],
|
||||
] as const
|
||||
)
|
||||
.filter(([, v]) => !v)
|
||||
.map(([name]) => name);
|
||||
},
|
||||
|
||||
async deliver(mobile, text) {
|
||||
const c = trezConfig();
|
||||
const reply = await postWithTimeout(
|
||||
c.url,
|
||||
{ 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
new URLSearchParams({
|
||||
PhoneNumber: c.senderLine,
|
||||
Username: c.username,
|
||||
Password: c.password,
|
||||
Mobile: mobile,
|
||||
Message: text,
|
||||
}).toString(),
|
||||
REQUEST_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
if (!reply.ok) return { ok: false, response: reply.body, error: `http-${reply.status}` };
|
||||
return { ok: true, response: reply.body };
|
||||
},
|
||||
};
|
||||
@@ -1,125 +1,308 @@
|
||||
import { NetworkInterfaceInfo, networkInterfaces } from 'node:os';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { assertSmsConfigured, smsConfig, smsConfigured } from './sms.service';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { DEFAULT_TREZ_URL, trezConfig } from './drivers/trez.driver';
|
||||
import { relayConfig, relayRejection } from './drivers/relay.driver';
|
||||
import {
|
||||
SmsService,
|
||||
assertSmsConfigured,
|
||||
smsConfigured,
|
||||
smsProvider,
|
||||
} from './sms.service';
|
||||
|
||||
const SMS_ENV = [
|
||||
'SMS_PROVIDER',
|
||||
'SMS_RELAY_URL',
|
||||
'SMS_RELAY_SECRET',
|
||||
'SMS_RELAY_HOST',
|
||||
'SMS_RELAY_PLATFORM',
|
||||
'SMS_TREZ_URL',
|
||||
'SMS_TREZ_USERNAME',
|
||||
'SMS_TREZ_PASSWORD',
|
||||
'SMS_TREZ_SENDER_LINE',
|
||||
'NODE_ENV',
|
||||
];
|
||||
|
||||
// Node's own module namespace cannot be spied on, so the lookup is mocked.
|
||||
jest.mock('node:os', () => ({ ...jest.requireActual('node:os'), networkInterfaces: jest.fn() }));
|
||||
const interfaces = networkInterfaces as jest.MockedFunction<typeof networkInterfaces>;
|
||||
|
||||
const iface = (address: string, internal: boolean) =>
|
||||
({ address, internal, family: 'IPv4' }) as NetworkInterfaceInfo;
|
||||
|
||||
/** A machine with only loopback, so the relay has no host to report. */
|
||||
const noExternalIp = () => interfaces.mockReturnValue({ lo: [iface('127.0.0.1', true)] });
|
||||
|
||||
const saved = { ...process.env };
|
||||
|
||||
beforeEach(() => {
|
||||
for (const key of SMS_ENV) delete process.env[key];
|
||||
jest.restoreAllMocks();
|
||||
interfaces.mockReturnValue({ eth0: [iface('10.9.9.9', false)] });
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
process.env = { ...saved };
|
||||
});
|
||||
|
||||
/**
|
||||
* Covers the boot-time contract as REVISED by D20:
|
||||
* * while no key is set, development behaviour is unchanged;
|
||||
* Covers the boot-time contract as REVISED by D20 and the driver choice of D26:
|
||||
* * a missing key NEVER stops the boot, in any environment — it warns, and
|
||||
* sending silently declines. (D14's "fail to boot in production" rule was
|
||||
* removed by the owner for the delivery.)
|
||||
* sending silently declines;
|
||||
* * `relay` is the default provider; `trez` stays selectable.
|
||||
*/
|
||||
describe('SMS configuration', () => {
|
||||
const saved = { ...process.env };
|
||||
// Silence the warnings these tests deliberately provoke.
|
||||
const logger = { warn: jest.fn(), log: jest.fn(), error: jest.fn() } as unknown as Logger;
|
||||
const warned = () => (logger.warn as jest.Mock).mock.calls.flat().join(' ');
|
||||
|
||||
beforeEach(() => {
|
||||
for (const key of [
|
||||
'SMS_PROVIDER',
|
||||
'SMS_TREZ_URL',
|
||||
'SMS_TREZ_USERNAME',
|
||||
'SMS_TREZ_PASSWORD',
|
||||
'SMS_TREZ_SENDER_LINE',
|
||||
'NODE_ENV',
|
||||
]) {
|
||||
delete process.env[key];
|
||||
}
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
afterAll(() => {
|
||||
process.env = { ...saved };
|
||||
});
|
||||
|
||||
const configureFully = () => {
|
||||
process.env.SMS_TREZ_USERNAME = 'u';
|
||||
process.env.SMS_TREZ_PASSWORD = 'p';
|
||||
process.env.SMS_TREZ_SENDER_LINE = '9998882797';
|
||||
};
|
||||
|
||||
describe('smsConfig', () => {
|
||||
it('defaults the provider to trez and the URL to the reference endpoint', () => {
|
||||
const c = smsConfig();
|
||||
expect(c.provider).toBe('trez');
|
||||
expect(c.url).toBe('https://smspanel.trez.ir/SendMessageWithCode.ashx');
|
||||
describe('provider selection (D26)', () => {
|
||||
it('defaults to relay', () => {
|
||||
expect(smsProvider()).toBe('relay');
|
||||
});
|
||||
|
||||
it('lets the URL be overridden for staging', () => {
|
||||
process.env.SMS_TREZ_URL = 'https://staging.example/send';
|
||||
expect(smsConfig().url).toBe('https://staging.example/send');
|
||||
it('treats a blank SMS_PROVIDER, as docker-compose passes it, as the default', () => {
|
||||
process.env.SMS_PROVIDER = '';
|
||||
expect(smsProvider()).toBe('relay');
|
||||
});
|
||||
|
||||
it('still selects trez on request', () => {
|
||||
process.env.SMS_PROVIDER = 'Trez';
|
||||
expect(smsProvider()).toBe('trez');
|
||||
});
|
||||
});
|
||||
|
||||
describe('smsConfigured', () => {
|
||||
it('is false when nothing is set', () => {
|
||||
expect(smsConfigured()).toBe(false);
|
||||
});
|
||||
|
||||
it('is false when only some credentials are set', () => {
|
||||
process.env.SMS_TREZ_USERNAME = 'u';
|
||||
process.env.SMS_TREZ_PASSWORD = 'p';
|
||||
expect(smsConfigured()).toBe(false);
|
||||
});
|
||||
|
||||
it('is true once all three are set', () => {
|
||||
configureFully();
|
||||
describe('relay', () => {
|
||||
it('works out of the box, sending through Kavenegar', () => {
|
||||
process.env.SMS_RELAY_HOST = '10.0.0.5';
|
||||
const c = relayConfig();
|
||||
expect(c.url).toBe('http://185.231.112.101/api/createsms');
|
||||
expect(c.secret).toBeTruthy();
|
||||
expect(c.platform).toBe('kave');
|
||||
expect(smsConfigured()).toBe(true);
|
||||
});
|
||||
|
||||
it('is false when the provider is explicitly disabled', () => {
|
||||
configureFully();
|
||||
process.env.SMS_PROVIDER = 'none';
|
||||
it('falls back to the server IP for host, skipping loopback', () => {
|
||||
interfaces.mockReturnValue({
|
||||
lo: [iface('127.0.0.1', true)],
|
||||
eth0: [iface('192.168.1.20', false)],
|
||||
});
|
||||
expect(relayConfig().host).toBe('192.168.1.20');
|
||||
});
|
||||
|
||||
it('lets SMS_RELAY_HOST override the server IP', () => {
|
||||
process.env.SMS_RELAY_HOST = 'gold.example';
|
||||
expect(relayConfig().host).toBe('gold.example');
|
||||
});
|
||||
|
||||
it('sends platform null when SMS_RELAY_PLATFORM=default', () => {
|
||||
process.env.SMS_RELAY_PLATFORM = 'default';
|
||||
expect(relayConfig().platform).toBeNull();
|
||||
});
|
||||
|
||||
it('is not configured, and warns by name, when no host can be found', () => {
|
||||
noExternalIp();
|
||||
expect(smsConfigured()).toBe(false);
|
||||
expect(() => assertSmsConfigured(logger)).not.toThrow();
|
||||
expect(warned()).toMatch(/SMS_RELAY_HOST/);
|
||||
});
|
||||
|
||||
it('passes silently once a host is known', () => {
|
||||
process.env.SMS_RELAY_HOST = '10.0.0.5';
|
||||
assertSmsConfigured(logger);
|
||||
expect(logger.warn).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* D20 REVERSED the original boot contract. SMS must never stop the server
|
||||
* from starting, in any environment — an earlier revision of these tests
|
||||
* asserted the opposite (a throw in production) and is intentionally gone.
|
||||
*/
|
||||
describe('assertSmsConfigured (D20 — never throws)', () => {
|
||||
it('warns outside production when keys are missing', () => {
|
||||
process.env.NODE_ENV = 'development';
|
||||
expect(() => assertSmsConfigured(logger)).not.toThrow();
|
||||
expect(logger.warn).toHaveBeenCalled();
|
||||
describe('trez', () => {
|
||||
beforeEach(() => {
|
||||
process.env.SMS_PROVIDER = 'trez';
|
||||
});
|
||||
|
||||
const configureFully = () => {
|
||||
process.env.SMS_TREZ_USERNAME = 'u';
|
||||
process.env.SMS_TREZ_PASSWORD = 'p';
|
||||
process.env.SMS_TREZ_SENDER_LINE = '9998882797';
|
||||
};
|
||||
|
||||
it('defaults the URL to the reference endpoint and lets staging override it', () => {
|
||||
expect(trezConfig().url).toBe(DEFAULT_TREZ_URL);
|
||||
process.env.SMS_TREZ_URL = 'https://staging.example/send';
|
||||
expect(trezConfig().url).toBe('https://staging.example/send');
|
||||
});
|
||||
|
||||
it('is configured only once all three credentials are set', () => {
|
||||
expect(smsConfigured()).toBe(false);
|
||||
process.env.SMS_TREZ_USERNAME = 'u';
|
||||
process.env.SMS_TREZ_PASSWORD = 'p';
|
||||
expect(smsConfigured()).toBe(false);
|
||||
process.env.SMS_TREZ_SENDER_LINE = '9998882797';
|
||||
expect(smsConfigured()).toBe(true);
|
||||
});
|
||||
|
||||
it('warns in production when keys are missing, naming them, and still boots', () => {
|
||||
process.env.NODE_ENV = 'production';
|
||||
expect(() => assertSmsConfigured(logger)).not.toThrow();
|
||||
const warned = (logger.warn as jest.Mock).mock.calls.flat().join(' ');
|
||||
expect(warned).toMatch(/SMS_TREZ_USERNAME/);
|
||||
expect(warned).toMatch(/SMS_TREZ_PASSWORD/);
|
||||
expect(warned).toMatch(/SMS_TREZ_SENDER_LINE/);
|
||||
expect(warned()).toMatch(/SMS_TREZ_USERNAME/);
|
||||
expect(warned()).toMatch(/SMS_TREZ_PASSWORD/);
|
||||
expect(warned()).toMatch(/SMS_TREZ_SENDER_LINE/);
|
||||
});
|
||||
|
||||
it('passes silently in production once everything is set', () => {
|
||||
process.env.NODE_ENV = 'production';
|
||||
configureFully();
|
||||
expect(() => assertSmsConfigured(logger)).not.toThrow();
|
||||
assertSmsConfigured(logger);
|
||||
expect(logger.warn).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('allows SMS to be explicitly disabled in production', () => {
|
||||
describe('none and unknown providers (D20 — never throws)', () => {
|
||||
it('allows SMS to be explicitly disabled, in production too', () => {
|
||||
process.env.NODE_ENV = 'production';
|
||||
process.env.SMS_PROVIDER = 'none';
|
||||
process.env.SMS_RELAY_HOST = '10.0.0.5';
|
||||
expect(smsConfigured()).toBe(false);
|
||||
expect(() => assertSmsConfigured(logger)).not.toThrow();
|
||||
expect(logger.warn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows SMS to be disabled outside production', () => {
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.SMS_PROVIDER = 'none';
|
||||
expect(() => assertSmsConfigured(logger)).not.toThrow();
|
||||
});
|
||||
|
||||
it('warns about an unknown provider name instead of refusing to boot', () => {
|
||||
process.env.SMS_PROVIDER = 'kavenegar';
|
||||
expect(smsConfigured()).toBe(false);
|
||||
expect(() => assertSmsConfigured(logger)).not.toThrow();
|
||||
expect((logger.warn as jest.Mock).mock.calls.flat().join(' ')).toMatch(
|
||||
/Unknown SMS_PROVIDER/,
|
||||
);
|
||||
expect(warned()).toMatch(/Unknown SMS_PROVIDER/);
|
||||
expect(warned()).toMatch(/"relay", "trez", "none"/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('relayRejection', () => {
|
||||
it('accepts empty and neutral JSON bodies', () => {
|
||||
expect(relayRejection('')).toBeNull();
|
||||
expect(relayRejection('{"status":true,"id":12}')).toBeNull();
|
||||
expect(relayRejection('{"status":"success"}')).toBeNull();
|
||||
expect(relayRejection('[1,2]')).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects bodies that say they failed', () => {
|
||||
expect(relayRejection('{"status":false}')).toBe('rejected-by-gateway');
|
||||
expect(relayRejection('{"success":false,"message":"bad phone"}')).toBe('bad phone');
|
||||
expect(relayRejection('{"status":"error"}')).toBe('rejected-by-gateway');
|
||||
expect(relayRejection('{"error":"invalid key"}')).toBe('invalid key');
|
||||
});
|
||||
|
||||
it('rejects a bare-text body — the gateway reports its own PHP errors that way', () => {
|
||||
expect(relayRejection('cURL library is not loaded')).toBe('cURL library is not loaded');
|
||||
});
|
||||
});
|
||||
|
||||
describe('SmsService.send', () => {
|
||||
const create = jest.fn();
|
||||
const service = new SmsService({ smsMessage: { create } } as unknown as PrismaService);
|
||||
let fetchMock: jest.SpyInstance;
|
||||
|
||||
const reply = (status: number, body: string) =>
|
||||
fetchMock.mockResolvedValue(new Response(body, { status }));
|
||||
|
||||
beforeEach(() => {
|
||||
create.mockReset().mockResolvedValue({});
|
||||
fetchMock = jest.spyOn(globalThis, 'fetch');
|
||||
// Keep the deliberate failure logs out of the test output.
|
||||
jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
|
||||
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
|
||||
process.env.SMS_RELAY_HOST = '10.0.0.5';
|
||||
});
|
||||
|
||||
it('posts the shape of the owner PHP client to the relay', async () => {
|
||||
reply(200, '{"status":true}');
|
||||
|
||||
const result = await service.send('09120000000', ' سلام ');
|
||||
|
||||
expect(result).toEqual({ ok: true, response: '{"status":true}' });
|
||||
const [url, init] = fetchMock.mock.calls[0];
|
||||
expect(url).toBe('http://185.231.112.101/api/createsms');
|
||||
expect(init.method).toBe('POST');
|
||||
expect(init.headers).toEqual({
|
||||
'Content-Type': 'application/json',
|
||||
secret_key: relayConfig().secret,
|
||||
});
|
||||
expect(JSON.parse(init.body)).toEqual({
|
||||
phone: '09120000000',
|
||||
host: '10.0.0.5',
|
||||
text: 'سلام',
|
||||
platform: 'kave',
|
||||
});
|
||||
expect(create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ recipients: '09120000000', body: 'سلام' }),
|
||||
});
|
||||
});
|
||||
|
||||
it('reports an HTTP error without throwing, and still records the message', async () => {
|
||||
reply(500, 'boom');
|
||||
await expect(service.send('09120000000', 'x')).resolves.toEqual({
|
||||
ok: false,
|
||||
response: 'boom',
|
||||
error: 'http-500',
|
||||
});
|
||||
expect(create).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports a 200 that the gateway marks as failed', async () => {
|
||||
reply(200, '{"status":false,"message":"no credit"}');
|
||||
const result = await service.send('09120000000', 'x');
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.error).toBe('no credit');
|
||||
});
|
||||
|
||||
it('turns a network failure into ok:false', async () => {
|
||||
fetchMock.mockRejectedValue(new Error('ECONNREFUSED'));
|
||||
await expect(service.send('09120000000', 'x')).resolves.toEqual({
|
||||
ok: false,
|
||||
error: 'ECONNREFUSED',
|
||||
});
|
||||
});
|
||||
|
||||
it('reports the gateway bare-text PHP error, as seen live', async () => {
|
||||
reply(200, 'cURL library is not loaded');
|
||||
await expect(service.send('09120000000', 'x')).resolves.toEqual({
|
||||
ok: false,
|
||||
response: 'cURL library is not loaded',
|
||||
error: 'cURL library is not loaded',
|
||||
});
|
||||
});
|
||||
|
||||
it('honours persist:false', async () => {
|
||||
reply(200, '{}');
|
||||
await service.send('09120000000', 'x', { persist: false });
|
||||
expect(create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('sends nothing when the provider is disabled', async () => {
|
||||
process.env.SMS_PROVIDER = 'none';
|
||||
const result = await service.send('09120000000', 'x');
|
||||
expect(result).toEqual({ ok: false, error: 'sms-not-configured' });
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('still drives trez when selected', async () => {
|
||||
process.env.SMS_PROVIDER = 'trez';
|
||||
process.env.SMS_TREZ_USERNAME = 'u';
|
||||
process.env.SMS_TREZ_PASSWORD = 'p';
|
||||
process.env.SMS_TREZ_SENDER_LINE = '9998882797';
|
||||
reply(200, '1');
|
||||
|
||||
await service.send('09120000000', 'x');
|
||||
|
||||
const [url, init] = fetchMock.mock.calls[0];
|
||||
expect(url).toBe(DEFAULT_TREZ_URL);
|
||||
expect(Object.fromEntries(new URLSearchParams(init.body))).toEqual({
|
||||
PhoneNumber: '9998882797',
|
||||
Username: 'u',
|
||||
Password: 'p',
|
||||
Mobile: '09120000000',
|
||||
Message: 'x',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,22 +2,22 @@ import { Injectable, Logger } from '@nestjs/common';
|
||||
import { MessageAudience } from '@prisma/client';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { envOr } from '../../common/env';
|
||||
import { DEFAULT_SMS_PROVIDER, SMS_DRIVERS, SmsDriver, SmsSendResult } from './drivers';
|
||||
|
||||
/**
|
||||
* SMS delivery — port of henza_server/app/Models/Sms.php.
|
||||
* SMS delivery — port of henza_server/app/Models/Sms.php, with the gateway
|
||||
* pulled out into drivers (`./drivers`) selected by `SMS_PROVIDER`:
|
||||
* * `relay` (default, D26) — the owner's createsms gateway
|
||||
* * `trez` — the reference's Trez panel
|
||||
* * `none` — send nothing
|
||||
*
|
||||
* Reference behaviour (`Sms::Send`, `Sms.php:57-82`):
|
||||
* * POST, form-urlencoded, to the Trez panel, 20s timeout
|
||||
* * fields: PhoneNumber (sender line), Username, Password, Mobile, Message
|
||||
* * the message is trim()ed before sending
|
||||
* Reference behaviour kept for every driver:
|
||||
* * the message is trim()ed before sending (`Sms.php:77`)
|
||||
* * EVERY message is persisted, via `Sms::set()` (`:80`)
|
||||
* * the response body is returned and the callers ignore it
|
||||
*
|
||||
* Two things differ, both deliberate:
|
||||
* 1. Credentials come from the environment. The reference hardcodes live
|
||||
* production credentials in the source (SEC-12); they are not copied here.
|
||||
* 2. The OTP message body is NOT persisted in readable form — see
|
||||
* CustomerAuthService.deliver() and SEC-11.
|
||||
* The OTP message body is NOT persisted in readable form — see
|
||||
* CustomerAuthService.deliver() and SEC-11.
|
||||
*
|
||||
* Failure handling matches the reference: `Sms::Send` is called inline and its
|
||||
* result is discarded, so a failed send never aborted the order, the document
|
||||
@@ -25,18 +25,7 @@ import { envOr } from '../../common/env';
|
||||
* never throws — it logs and reports failure in its return value.
|
||||
*/
|
||||
|
||||
/** Reference endpoint (`Sms.php:69`). Overridable for staging. */
|
||||
const DEFAULT_TREZ_URL = 'https://smspanel.trez.ir/SendMessageWithCode.ashx';
|
||||
|
||||
/** `Http::timeout(20)` in the reference (`Sms.php:72`). */
|
||||
const REQUEST_TIMEOUT_MS = 20_000;
|
||||
|
||||
export interface SmsSendResult {
|
||||
ok: boolean;
|
||||
/** Provider response body, when one was received. */
|
||||
response?: string;
|
||||
error?: string;
|
||||
}
|
||||
export type { SmsSendResult } from './drivers';
|
||||
|
||||
export interface SmsSendOptions {
|
||||
/**
|
||||
@@ -47,15 +36,14 @@ export interface SmsSendOptions {
|
||||
audience?: MessageAudience;
|
||||
}
|
||||
|
||||
/** Reads the provider config. Exported so main.ts can validate it at boot. */
|
||||
export function smsConfig() {
|
||||
return {
|
||||
provider: envOr('SMS_PROVIDER', 'trez').toLowerCase(),
|
||||
url: envOr('SMS_TREZ_URL', DEFAULT_TREZ_URL),
|
||||
username: process.env.SMS_TREZ_USERNAME ?? '',
|
||||
password: process.env.SMS_TREZ_PASSWORD ?? '',
|
||||
senderLine: process.env.SMS_TREZ_SENDER_LINE ?? '',
|
||||
};
|
||||
/** The selected `SMS_PROVIDER`, lower-cased. */
|
||||
export function smsProvider(): string {
|
||||
return envOr('SMS_PROVIDER', DEFAULT_SMS_PROVIDER).toLowerCase();
|
||||
}
|
||||
|
||||
/** The selected driver, or null for `none` and unknown names. */
|
||||
export function smsDriver(): SmsDriver | null {
|
||||
return SMS_DRIVERS[smsProvider()] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -63,9 +51,8 @@ export function smsConfig() {
|
||||
* development mode: nothing is sent, and OTP codes are logged locally.
|
||||
*/
|
||||
export function smsConfigured(): boolean {
|
||||
const c = smsConfig();
|
||||
if (c.provider === 'none') return false;
|
||||
return Boolean(c.username && c.password && c.senderLine);
|
||||
const driver = smsDriver();
|
||||
return driver !== null && driver.missingConfig().length === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -80,35 +67,30 @@ export function smsConfigured(): boolean {
|
||||
* login code, which is why `OTP_MASTER_CODE` exists for the delivery.
|
||||
*/
|
||||
export function assertSmsConfigured(logger: Logger = new Logger('SmsConfig')): void {
|
||||
const c = smsConfig();
|
||||
const provider = smsProvider();
|
||||
|
||||
if (c.provider === 'none') {
|
||||
if (provider === 'none') {
|
||||
logger.warn('SMS_PROVIDER=none — no SMS will be sent.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (c.provider !== 'trez') {
|
||||
const driver = smsDriver();
|
||||
if (!driver) {
|
||||
const supported = [...Object.keys(SMS_DRIVERS), 'none'].map((n) => `"${n}"`).join(', ');
|
||||
logger.warn(
|
||||
`Unknown SMS_PROVIDER "${c.provider}" (supported: "trez", "none"). ` +
|
||||
'No SMS will be sent.',
|
||||
`Unknown SMS_PROVIDER "${provider}" (supported: ${supported}). No SMS will be sent.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const missing = (
|
||||
[
|
||||
['SMS_TREZ_USERNAME', c.username],
|
||||
['SMS_TREZ_PASSWORD', c.password],
|
||||
['SMS_TREZ_SENDER_LINE', c.senderLine],
|
||||
] as const
|
||||
)
|
||||
.filter(([, v]) => !v)
|
||||
.map(([name]) => name);
|
||||
|
||||
if (missing.length === 0) return;
|
||||
const missing = driver.missingConfig();
|
||||
if (missing.length === 0) {
|
||||
logger.log(`SMS provider: ${driver.name}`);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.warn(
|
||||
`SMS provider "trez" is selected but these are not set: ${missing.join(', ')}. ` +
|
||||
`SMS provider "${driver.name}" is selected but these are not set: ${missing.join(', ')}. ` +
|
||||
'Messages are logged, not sent. Customers cannot receive a login code.',
|
||||
);
|
||||
}
|
||||
@@ -128,14 +110,8 @@ export class SmsService {
|
||||
// The reference trims before sending (`Sms.php:77`).
|
||||
const text = body.trim();
|
||||
|
||||
let result: SmsSendResult;
|
||||
|
||||
if (!smsConfigured()) {
|
||||
this.logger.warn(`[dev] SMS not configured; not sending to ${mobile}`);
|
||||
result = { ok: false, error: 'sms-not-configured' };
|
||||
} else {
|
||||
result = await this.deliverViaTrez(mobile, text);
|
||||
}
|
||||
console.log("mobile ==>" ,mobile)
|
||||
const result = await this.deliver(mobile, text);
|
||||
|
||||
if (persist) {
|
||||
// `Sms::set()` records every message, delivered or not.
|
||||
@@ -145,42 +121,26 @@ export class SmsService {
|
||||
return result;
|
||||
}
|
||||
|
||||
/** POST to the Trez panel, shaped exactly as the reference's form post. */
|
||||
private async deliverViaTrez(mobile: string, text: string): Promise<SmsSendResult> {
|
||||
const c = smsConfig();
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const response = await fetch(c.url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
PhoneNumber: c.senderLine,
|
||||
Username: c.username,
|
||||
Password: c.password,
|
||||
Mobile: mobile,
|
||||
Message: text,
|
||||
}).toString(),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
const responseBody = await response.text();
|
||||
|
||||
if (!response.ok) {
|
||||
// Logged, not thrown — see the class comment.
|
||||
this.logger.error(`SMS to ${mobile} failed: HTTP ${response.status} ${responseBody}`);
|
||||
return { ok: false, response: responseBody, error: `http-${response.status}` };
|
||||
}
|
||||
|
||||
return { ok: true, response: responseBody };
|
||||
} catch (e) {
|
||||
const error = e instanceof Error ? e.message : String(e);
|
||||
this.logger.error(`SMS to ${mobile} failed: ${error}`);
|
||||
return { ok: false, error };
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
private async deliver(mobile: string, text: string): Promise<SmsSendResult> {
|
||||
const driver = smsDriver();
|
||||
if (!driver || driver.missingConfig().length > 0) {
|
||||
this.logger.warn(`[dev] SMS not configured; not sending to ${mobile}`);
|
||||
return { ok: false, error: 'sms-not-configured' };
|
||||
}
|
||||
|
||||
let result: SmsSendResult;
|
||||
try {
|
||||
result = await driver.deliver(mobile, text);
|
||||
} catch (e) {
|
||||
result = { ok: false, error: e instanceof Error ? e.message : String(e) };
|
||||
}
|
||||
|
||||
if (!result.ok) {
|
||||
// Logged, not thrown — see the class comment.
|
||||
const detail = result.response ? ` ${result.response}` : '';
|
||||
this.logger.error(`SMS to ${mobile} via ${driver.name} failed: ${result.error}${detail}`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** The `messages` row the reference writes for every send (`Sms.php:120-129`). */
|
||||
|
||||
Reference in New Issue
Block a user