feat(internal-admin): refund + generalize subscription extend #30

Merged
root merged 2 commits from dev into main 2026-05-18 10:03:15 +03:00
5 changed files with 350 additions and 8 deletions

View File

@@ -27,6 +27,9 @@ import {
CATALOG_MAP,
type DecodedVehicle,
type EmexCategoryTreeNode,
type EmexHotspot,
type EmexHotspotArea,
type EmexPart,
type EmexPartsResult,
type EmexScraperResponse,
} from "./emex.types";
@@ -696,7 +699,19 @@ export class EmexService {
}
/**
* Fetches parts + schema image for a specific category (on-demand)
* Fetches parts + schema image for a specific category (on-demand).
*
* Strategy: plain-HTTP fast path first, browser fallback on parse failure.
* The probe in scripts/dev (FN-* perf work) showed Unit.aspx is fully
* server-rendered for the data we need — parts come from `<tr name>` rows
* with `td[name=c_oem|c_pnc|c_name]`, hotspots from inline-styled
* `<div class="dragger g_highlight" name=N style="margin-top:Ypx; margin-left:Xpx; width:Wpx; height:Hpx">`,
* and image dims from the first 24 bytes of the GIF/PNG itself. Sidesteps
* the ~1-2s browser-launch cost AND the 3-page semaphore in EmexBrowserService,
* dropping a cold leaf from ~6-7s (Tier 1) to ~5s and removing the
* concurrency cap (prefetch can fan out beyond 3 simultaneous fetches).
* Falls back to the Playwright scraper if the HTML doesn't yield parts —
* keeps us honest when emexdwc.ae changes layout or returns a JS-gated page.
*/
async fetchCategoryParts(categoryUrl: string): Promise<EmexPartsResult> {
if (!categoryUrl) {
@@ -707,8 +722,26 @@ export class EmexService {
this.logger.log(`Fetching parts from category URL: ${categoryUrl}`);
await this.touchActivity();
let release: (() => Promise<void>) | null = null;
// Fast path: plain HTTP + HTML parse. Browser-free.
try {
const httpResult = await this.fetchCategoryPartsViaHttp(categoryUrl);
if (httpResult.parts.length > 0) {
this.logger.log(
`Fetched ${httpResult.parts.length} parts from category (http path${httpResult.schemaImageUrl ? ", with schema" : ""})`,
);
return httpResult;
}
this.logger.log(
"Plain-HTTP path returned 0 parts; falling back to Playwright scraper",
);
} catch (err) {
this.logger.warn(
`Plain-HTTP path failed: ${(err as Error).message}; falling back to Playwright`,
);
}
// Slow path: existing Playwright scraper.
let release: (() => Promise<void>) | null = null;
try {
const instance = await this.createScraperInstance();
const scraper = instance.scraper;
@@ -717,7 +750,7 @@ export class EmexService {
const result = await this.executeWithTimeout(scraper.getParts(categoryUrl), this.timeout);
if (result && result.parts.length > 0) {
this.logger.log(`Fetched ${result.parts.length} parts from category`);
this.logger.log(`Fetched ${result.parts.length} parts from category (browser path)`);
if (result.schemaImageUrl) {
this.logger.log(`Schema image found: ${result.schemaImageUrl}`);
}
@@ -741,6 +774,167 @@ export class EmexService {
}
}
/**
* Plain-HTTP equivalent of the Playwright getParts() flow. Two sequential
* GETs (QuickDetails → Unit) + one Range GET for image dims. Throws if it
* can't resolve the Unit.aspx URL or if the image is fetched but headers
* are unparseable. Returns an empty result (no throw) if the page has no
* parts — caller treats that as "fall back to browser".
*/
private async fetchCategoryPartsViaHttp(categoryUrl: string): Promise<EmexPartsResult> {
// 1) QuickDetails.aspx → Unit.aspx anchor
const qdHtml = await this.fetchEmexHtml(categoryUrl);
const unitMatch = qdHtml.match(/href="([^"]*Unit\.aspx[^"]*)"/i);
if (!unitMatch) {
// QuickDetails returned an Error.aspx-style page or has parts inline —
// try extracting parts directly; if none, signal fallback.
const direct = this.extractEmexPartsFromHtml(qdHtml);
if (direct.parts.length > 0) return direct;
throw new Error("No Unit.aspx link in QuickDetails response");
}
const unitRel = unitMatch[1].replace(/&amp;/g, "&");
const unitUrl = unitRel.startsWith("http")
? unitRel
: new URL(unitRel, categoryUrl).toString();
// 2) Unit.aspx — main extraction target
const unitHtml = await this.fetchEmexHtml(unitUrl);
const extracted = this.extractEmexPartsFromHtml(unitHtml);
if (extracted.parts.length === 0) {
// Empty parts table: structural change or session-gated page. Let the
// caller try Playwright (which sometimes succeeds where plain HTTP
// doesn't, e.g. if a JS redirect refreshes the ssd token).
return extracted;
}
// 3) Image dims — read GIF/PNG header from a 128-byte Range GET if a
// schema URL was found. Don't block parts extraction on image failure.
if (extracted.schemaImageUrl && (extracted.schemaWidth === 0 || extracted.schemaHeight === 0)) {
try {
const dims = await this.fetchImageDims(extracted.schemaImageUrl);
if (dims.width > 0 && dims.height > 0) {
extracted.schemaWidth = dims.width;
extracted.schemaHeight = dims.height;
}
} catch (err) {
this.logger.debug(`Image dims fetch failed: ${(err as Error).message}`);
}
}
if (extracted.schemaImageUrl) {
this.logger.log(`Schema image found: ${extracted.schemaImageUrl}`);
}
return extracted;
}
/**
* Parse Unit.aspx (or fallback QuickDetails.aspx) HTML into the same shape
* the Playwright scraper returns. Pure regex/string-based — no DOM, no
* browser. Mirrors the page.evaluate() in scripts/emex-vin-scraper.js so
* upstream consumers don't care which path produced the result.
*/
private extractEmexPartsFromHtml(html: string): EmexPartsResult {
// Parts: <tr name="..."> with <td name="c_oem">, <td name="c_pnc">, <td name="c_name">.
const parts: EmexPart[] = [];
const trRx = /<tr\b[^>]*\bname="[^"]+"[^>]*>([\s\S]*?)<\/tr>/g;
const stripTags = (s: string) =>
s
.replace(/<[^>]+>/g, "")
.replace(/&nbsp;/g, " ")
.replace(/&amp;/g, "&")
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.trim();
for (const trMatch of html.matchAll(trRx)) {
const body = trMatch[1];
const oem = stripTags(body.match(/<td\b[^>]*\bname="c_oem"[^>]*>([\s\S]*?)<\/td>/)?.[1] ?? "");
if (!oem) continue;
const pnc = stripTags(body.match(/<td\b[^>]*\bname="c_pnc"[^>]*>([\s\S]*?)<\/td>/)?.[1] ?? "");
const name = stripTags(
body.match(/<td\b[^>]*\bname="c_name"[^>]*>([\s\S]*?)<\/td>/)?.[1] ?? "",
);
parts.push({ oemCode: oem, nameEn: name, positionCode: pnc });
}
// Hotspots: <div name="N" class="... dragger ... g_highlight ..." style="…margin-top:Ypx; margin-left:Xpx; width:Wpx; height:Hpx…">.
// Inline style is in image-natural pixel coordinates (probe confirmed
// max-right and max-bottom always sit within the image's natural dims),
// so we use the values directly — no rect arithmetic or scaling.
const hotspotMap = new Map<string, EmexHotspot>();
const divRx =
/<div\b[^>]*\bname="([^"]+)"[^>]*\bclass="[^"]*\bdragger\b[^"]*\bg_highlight\b[^"]*"[^>]*\bstyle="([^"]+)"/g;
for (const m of html.matchAll(divRx)) {
const key = m[1];
const style = m[2];
const w = Number.parseInt(style.match(/width:\s*(\d+)px/)?.[1] ?? "0", 10);
const h = Number.parseInt(style.match(/height:\s*(\d+)px/)?.[1] ?? "0", 10);
const top = Number.parseInt(style.match(/margin-top:\s*(\d+)px/)?.[1] ?? "0", 10);
const left = Number.parseInt(style.match(/margin-left:\s*(\d+)px/)?.[1] ?? "0", 10);
const area: EmexHotspotArea = { left, top, width: w, height: h };
const entry = hotspotMap.get(key);
if (entry) entry.areas.push(area);
else hotspotMap.set(key, { key, areas: [area] });
}
const hotspots = Array.from(hotspotMap.values());
// Schema image: <img class="dragger" src="...img.laximo.net/.../*.gif">.
// Fallback to any img.laximo.net URL, normalising the /NNN/ path to
// /source/ the same way the browser scraper did (some catalog pages link
// to a thumbnail variant that doesn't carry full-resolution coords).
let schemaImageUrl: string | null = null;
const draggerMatch = html.match(
/<img\b[^>]*\bclass="[^"]*\bdragger\b[^"]*"[^>]*\bsrc="([^"]+laximo[^"]+)"/,
);
if (draggerMatch) schemaImageUrl = draggerMatch[1].replace(/&amp;/g, "&");
if (!schemaImageUrl) {
const fallback = html.match(/src="(https?:\/\/img\.laximo\.net[^"]+)"/);
if (fallback) {
schemaImageUrl = fallback[1].replace(/&amp;/g, "&").replace(/\/\d+\//, "/source/");
}
}
return {
parts,
schemaImageUrl,
hotspots,
schemaWidth: 0,
schemaHeight: 0,
};
}
/**
* Read the natural image dimensions from the first ~24 bytes of a GIF or
* PNG. Uses an HTTP Range request so we never download the whole image
* just to read its size. Returns 0×0 on unknown formats — the schema-image
* downloader downstream uses its own dim-parsing fallback as a safety net.
*/
private async fetchImageDims(url: string): Promise<{ width: number; height: number }> {
const res = await fetch(url, {
headers: { "User-Agent": EMEX_UA, Range: "bytes=0-127" },
signal: AbortSignal.timeout(10000),
...(this.proxyAgent ? { dispatcher: this.proxyAgent } : {}),
} as RequestInit);
if (!res.ok && res.status !== 206) {
throw new Error(`Image HTTP ${res.status}`);
}
const buf = Buffer.from(await res.arrayBuffer());
// GIF87a / GIF89a — width at byte 6 LE, height at byte 8 LE.
if (buf.length >= 10 && buf.slice(0, 3).toString("ascii") === "GIF") {
return { width: buf.readUInt16LE(6), height: buf.readUInt16LE(8) };
}
// PNG — width at byte 16 BE, height at byte 20 BE.
if (
buf.length >= 24 &&
buf[0] === 0x89 &&
buf.slice(1, 4).toString("ascii") === "PNG"
) {
return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
}
return { width: 0, height: 0 };
}
/**
* Extracts year from VIN (10th character)
*/

View File

@@ -20,6 +20,11 @@ export class BillingService {
private subscriptionsService: SubscriptionsService,
) {}
/**
* Extend a subscription's endDate by N days. Works on trial OR active
* subscriptions; the panel uses "Trial uzat" copy for trial and
* "Bonus süre ekle" (goodwill) for active.
*/
async extendTrial(input: {
subscriptionId: string;
days: number;
@@ -35,9 +40,9 @@ export class BillingService {
.where(eq(userSubscriptions.id, input.subscriptionId))
.limit(1);
if (!sub) throw new NotFoundException("Subscription bulunamadı");
if (sub.status !== "trial") {
if (sub.status !== "trial" && sub.status !== "active") {
throw new ConflictException(
`Subscription '${sub.status}' durumunda — trial extend yalnız trial için çalışır`,
`Subscription '${sub.status}' durumunda — extend yalnız trial veya active için çalışır`,
);
}
@@ -53,7 +58,7 @@ export class BillingService {
.returning();
this.logger.log(
`trial extend: subscription=${input.subscriptionId} +${input.days}d ` +
`extend: subscription=${input.subscriptionId} (${sub.status}) +${input.days}d ` +
`${sub.endDate?.toISOString() ?? "—"}${newEnd.toISOString()} ` +
`founder=${input.founderId} reason="${input.reason.slice(0, 80)}"`,
);
@@ -62,6 +67,7 @@ export class BillingService {
success: true,
subscriptionId: input.subscriptionId,
userId: sub.userId,
subscriptionStatus: sub.status,
previousEndDate: sub.endDate?.toISOString() ?? null,
newEndDate: updated.endDate?.toISOString() ?? null,
daysAdded: input.days,

View File

@@ -1,4 +1,5 @@
import { Module } from "@nestjs/common";
import { StripeModule } from "../payments/stripe/stripe.module";
import { SubscriptionsModule } from "../subscriptions/subscriptions.module";
import { BillingController } from "./billing.controller";
import { BillingService } from "./billing.service";
@@ -6,10 +7,16 @@ import { ImpersonationController } from "./impersonation.controller";
import { ImpersonationService } from "./impersonation.service";
import { LifecycleController } from "./lifecycle.controller";
import { LifecycleService } from "./lifecycle.service";
import { PaymentsAdminController } from "./payments.controller";
@Module({
imports: [SubscriptionsModule],
controllers: [ImpersonationController, LifecycleController, BillingController],
imports: [SubscriptionsModule, StripeModule],
controllers: [
ImpersonationController,
LifecycleController,
BillingController,
PaymentsAdminController,
],
providers: [ImpersonationService, LifecycleService, BillingService],
})
export class InternalAdminModule {}

View File

@@ -0,0 +1,38 @@
import {
BadRequestException,
Body,
Controller,
HttpCode,
HttpStatus,
Param,
Post,
UseGuards,
} from "@nestjs/common";
import { Public } from "../common/decorators/public.decorator";
import { InternalTokenGuard } from "../common/guards/internal-token.guard";
import { StripeService } from "../payments/stripe/stripe.service";
@Controller("internal/admin/payments")
@Public()
@UseGuards(InternalTokenGuard)
export class PaymentsAdminController {
constructor(private stripeService: StripeService) {}
@Post(":id/refund")
@HttpCode(HttpStatus.OK)
async refund(
@Param("id") id: string,
@Body() body: { amount?: number; reason?: string; founderId?: string },
) {
if (!body.founderId) throw new BadRequestException("founderId required");
if (!body.reason || body.reason.trim().length < 5) {
throw new BadRequestException("reason required (min 5 chars)");
}
return this.stripeService.refundPayment({
paymentId: id,
amount: body.amount,
reason: body.reason,
founderId: body.founderId,
});
}
}

View File

@@ -283,4 +283,101 @@ export class StripeService {
reason,
});
}
/**
* Refund a completed Stripe payment. Called from the InternalAdmin module
* via Süper Panel. `amount` is in the smallest currency unit (kuruş for TRY)
* — omit for a full refund.
*
* Side effects:
* - Stripe refund created (idempotent via metadata.panel_payment_id).
* - payments.status flipped to 'refunded' (full) or 'partially_refunded' (partial).
* - adminNote prefixed with the reason for an audit breadcrumb.
* - PostHog event captured for the user.
*
* Does NOT cancel the subscription — that's a separate panel decision.
*/
async refundPayment(input: {
paymentId: string;
amount?: number;
reason: string;
founderId: string;
}) {
if (!this.stripe) {
throw new ServiceUnavailableException("Stripe ödeme şu an kullanılamıyor");
}
const [payment] = await this.db
.select()
.from(payments)
.where(eq(payments.id, input.paymentId))
.limit(1);
if (!payment) throw new NotFoundException("Payment bulunamadı");
if (!payment.stripePaymentIntentId) {
throw new BadRequestException(
"Bu payment Stripe üzerinden alınmadı (legacy/Iyzico)",
);
}
if (payment.status !== "completed" && payment.status !== "partially_refunded") {
throw new BadRequestException(
`Refund yalnız completed/partially_refunded ödemeler için ('${payment.status}')`,
);
}
if (
input.amount !== undefined &&
(input.amount <= 0 || input.amount > Number(payment.amount))
) {
throw new BadRequestException(
`Refund tutarı 1..${payment.amount} aralığında olmalı`,
);
}
const isFullRefund = input.amount === undefined;
const refund = await this.stripe.refunds.create({
payment_intent: payment.stripePaymentIntentId,
amount: isFullRefund ? undefined : input.amount,
reason: "requested_by_customer",
metadata: {
panel_payment_id: payment.id,
panel_reason: input.reason.slice(0, 480),
panel_founder_id: input.founderId,
},
});
const newStatus = isFullRefund ? "refunded" : "partially_refunded";
const adminNoteLine = `[refund ${new Date().toISOString().slice(0, 10)}] ${isFullRefund ? "full" : `${input.amount}`}${input.reason.slice(0, 200)}`;
const newAdminNote = payment.adminNote
? `${payment.adminNote}\n${adminNoteLine}`
: adminNoteLine;
await this.db
.update(payments)
.set({ status: newStatus, adminNote: newAdminNote, updatedAt: new Date() })
.where(eq(payments.id, input.paymentId));
this.posthog.captureForUser(payment.userId, "payment_refunded", {
payment_id: payment.id,
subscription_id: payment.subscriptionId,
amount_refunded: isFullRefund ? Number(payment.amount) : input.amount,
stripe_refund_id: refund.id,
is_full: isFullRefund,
via: "super_panel",
});
this.logger.log(
`refund: payment=${payment.id} ${isFullRefund ? "full" : input.amount} ` +
`stripe=${refund.id} founder=${input.founderId} reason="${input.reason.slice(0, 80)}"`,
);
return {
success: true,
paymentId: payment.id,
userId: payment.userId,
stripeRefundId: refund.id,
amount: isFullRefund ? Number(payment.amount) : input.amount!,
isFullRefund,
newStatus,
currency: payment.currency,
};
}
}