feat(internal-admin): refund + generalize subscription extend #30
@@ -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(/&/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(/ /g, " ")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/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(/&/g, "&");
|
||||
if (!schemaImageUrl) {
|
||||
const fallback = html.match(/src="(https?:\/\/img\.laximo\.net[^"]+)"/);
|
||||
if (fallback) {
|
||||
schemaImageUrl = fallback[1].replace(/&/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)
|
||||
*/
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
38
apps/api/src/internal-admin/payments.controller.ts
Normal file
38
apps/api/src/internal-admin/payments.controller.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user