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
@@ -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