Add after-sales returns and claims with PayU refunds and guest magic links.
continuous-integration/drone/push Build is passing

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Rafał MiczekandCursor committed 2026-10-19 21:09:32 +02:00
1 parent 2623664504
commit f41d12b64f
41 files changed
+5316 -6

No files matched your search

+7
View File
@@ -46,6 +46,13 @@ export const routes: Routes = [
(m) => m.OrdersAdminComponent, (m) => m.OrdersAdminComponent,
), ),
}, },
{
path: 'after-sales',
loadComponent: () =>
import('./features/after-sales/after-sales-admin.component').then(
(m) => m.AfterSalesAdminComponent,
),
},
{ {
path: 'inventory', path: 'inventory',
loadComponent: () => loadComponent: () =>
@@ -8,6 +8,42 @@ export const CS_TRANSLATIONS: TranslationMap = {
'nav.analytics': 'Analytika', 'nav.analytics': 'Analytika',
'nav.products': 'Produkty', 'nav.products': 'Produkty',
'nav.orders': 'Objednávky', 'nav.orders': 'Objednávky',
'nav.afterSales': 'Reklamace',
'afterSales.title': 'Reklamace a vrácení',
'afterSales.purpose':
'Kontrola žádostí. Potvrďte refundaci PayU, výměnu nebo zamítnutí.',
'afterSales.filterStatus': 'Stav',
'afterSales.colCreated': 'Vytvořeno',
'afterSales.colOrder': 'Objednávka',
'afterSales.colCustomer': 'Zákazník',
'afterSales.colType': 'Typ',
'afterSales.open': 'Otevřít',
'afterSales.empty': 'Žádné žádosti.',
'afterSales.lines': 'Položky',
'afterSales.customerNote': 'Poznámka zákazníka',
'afterSales.photos': 'Fotky',
'afterSales.resolution': 'Rozhodnutí',
'afterSales.resolveTitle': 'Rozhodnout',
'afterSales.adminNote': 'Poznámka admina',
'afterSales.refundAmount': 'Částka refundace',
'afterSales.confirmResolve': 'Potvrdit',
'afterSales.errorLoad': 'Nepodařilo se načíst žádosti.',
'afterSales.errorResolve': 'Nepodařilo se uzavřít žádost.',
'afterSales.type.Withdrawal': 'Vrácení / odstoupení',
'afterSales.type.Defect': 'Vada',
'afterSales.type.MissingPart': 'Chybějící díl',
'afterSales.type.WrongItem': 'Špatný produkt',
'afterSales.type.Other': 'Jiné',
'afterSales.status.Submitted': 'Podáno',
'afterSales.status.InReview': 'V řešení',
'afterSales.status.Approved': 'Schváleno',
'afterSales.status.Rejected': 'Zamítnuto',
'afterSales.status.Resolved': 'Vyřízeno',
'afterSales.resolution.Refund': 'Refundace (PayU)',
'afterSales.resolution.Replacement': 'Poslat náhradu',
'afterSales.resolution.SendMissingPart': 'Poslat chybějící díl',
'afterSales.resolution.Reject': 'Zamítnout',
'afterSales.resolution.Other': 'Jiné',
'nav.promotions': 'Akce', 'nav.promotions': 'Akce',
'nav.discounts': 'Akce', 'nav.discounts': 'Akce',
'nav.newsletter': 'Newsletter', 'nav.newsletter': 'Newsletter',
@@ -8,6 +8,42 @@ export const DE_TRANSLATIONS: TranslationMap = {
'nav.analytics': 'Analysen', 'nav.analytics': 'Analysen',
'nav.products': 'Produkte', 'nav.products': 'Produkte',
'nav.orders': 'Bestellungen', 'nav.orders': 'Bestellungen',
'nav.afterSales': 'After-Sales',
'afterSales.title': 'After-Sales',
'afterSales.purpose':
'Retouren und Reklamationen prüfen. Rückerstattung, Ersatz oder Ablehnung bestätigen.',
'afterSales.filterStatus': 'Status',
'afterSales.colCreated': 'Erstellt',
'afterSales.colOrder': 'Bestellung',
'afterSales.colCustomer': 'Kunde',
'afterSales.colType': 'Typ',
'afterSales.open': 'Öffnen',
'afterSales.empty': 'Keine Anfragen.',
'afterSales.lines': 'Positionen',
'afterSales.customerNote': 'Kundennotiz',
'afterSales.photos': 'Fotos',
'afterSales.resolution': 'Entscheidung',
'afterSales.resolveTitle': 'Entscheiden',
'afterSales.adminNote': 'Admin-Notiz',
'afterSales.refundAmount': 'Erstattungsbetrag',
'afterSales.confirmResolve': 'Bestätigen',
'afterSales.errorLoad': 'Anfragen konnten nicht geladen werden.',
'afterSales.errorResolve': 'Anfrage konnte nicht abgeschlossen werden.',
'afterSales.type.Withdrawal': 'Widerruf / Rückgabe',
'afterSales.type.Defect': 'Defekt',
'afterSales.type.MissingPart': 'Fehlendes Teil',
'afterSales.type.WrongItem': 'Falscher Artikel',
'afterSales.type.Other': 'Sonstiges',
'afterSales.status.Submitted': 'Eingereicht',
'afterSales.status.InReview': 'In Prüfung',
'afterSales.status.Approved': 'Genehmigt',
'afterSales.status.Rejected': 'Abgelehnt',
'afterSales.status.Resolved': 'Erledigt',
'afterSales.resolution.Refund': 'Erstattung (PayU)',
'afterSales.resolution.Replacement': 'Ersatz senden',
'afterSales.resolution.SendMissingPart': 'Fehlendes Teil senden',
'afterSales.resolution.Reject': 'Ablehnen',
'afterSales.resolution.Other': 'Sonstiges',
'nav.promotions': 'Aktionen', 'nav.promotions': 'Aktionen',
'nav.discounts': 'Aktionen', 'nav.discounts': 'Aktionen',
'nav.newsletter': 'Newsletter', 'nav.newsletter': 'Newsletter',
@@ -8,6 +8,42 @@ export const EN_TRANSLATIONS: TranslationMap = {
'nav.analytics': 'Analytics', 'nav.analytics': 'Analytics',
'nav.products': 'Products', 'nav.products': 'Products',
'nav.orders': 'Orders', 'nav.orders': 'Orders',
'nav.afterSales': 'After-sales',
'afterSales.title': 'After-sales',
'afterSales.purpose':
'Review return and claim requests. Confirm refunds, replacements, or rejections.',
'afterSales.filterStatus': 'Status',
'afterSales.colCreated': 'Created',
'afterSales.colOrder': 'Order',
'afterSales.colCustomer': 'Customer',
'afterSales.colType': 'Type',
'afterSales.open': 'Open',
'afterSales.empty': 'No after-sales requests.',
'afterSales.lines': 'Items',
'afterSales.customerNote': 'Customer note',
'afterSales.photos': 'Photos',
'afterSales.resolution': 'Resolution',
'afterSales.resolveTitle': 'Decide',
'afterSales.adminNote': 'Admin note',
'afterSales.refundAmount': 'Refund amount',
'afterSales.confirmResolve': 'Confirm',
'afterSales.errorLoad': 'Could not load after-sales requests.',
'afterSales.errorResolve': 'Could not resolve request.',
'afterSales.type.Withdrawal': 'Withdrawal / return',
'afterSales.type.Defect': 'Defect',
'afterSales.type.MissingPart': 'Missing part',
'afterSales.type.WrongItem': 'Wrong item',
'afterSales.type.Other': 'Other',
'afterSales.status.Submitted': 'Submitted',
'afterSales.status.InReview': 'In review',
'afterSales.status.Approved': 'Approved',
'afterSales.status.Rejected': 'Rejected',
'afterSales.status.Resolved': 'Resolved',
'afterSales.resolution.Refund': 'Refund (PayU)',
'afterSales.resolution.Replacement': 'Send replacement',
'afterSales.resolution.SendMissingPart': 'Send missing part',
'afterSales.resolution.Reject': 'Reject',
'afterSales.resolution.Other': 'Other',
'nav.promotions': 'Promotions', 'nav.promotions': 'Promotions',
'nav.discounts': 'Promotions', 'nav.discounts': 'Promotions',
'nav.newsletter': 'Newsletter', 'nav.newsletter': 'Newsletter',
@@ -8,6 +8,42 @@ export const PL_TRANSLATIONS: TranslationMap = {
'nav.analytics': 'Analityka', 'nav.analytics': 'Analityka',
'nav.products': 'Produkty', 'nav.products': 'Produkty',
'nav.orders': 'Zamówienia', 'nav.orders': 'Zamówienia',
'nav.afterSales': 'Reklamacje',
'afterSales.title': 'Reklamacje i zwroty',
'afterSales.purpose':
'Przeglądaj zgłoszenia. Potwierdź zwrot PayU, wymianę lub odrzucenie.',
'afterSales.filterStatus': 'Status',
'afterSales.colCreated': 'Utworzono',
'afterSales.colOrder': 'Zamówienie',
'afterSales.colCustomer': 'Klient',
'afterSales.colType': 'Typ',
'afterSales.open': 'Otwórz',
'afterSales.empty': 'Brak zgłoszeń.',
'afterSales.lines': 'Pozycje',
'afterSales.customerNote': 'Notatka klienta',
'afterSales.photos': 'Zdjęcia',
'afterSales.resolution': 'Rozstrzygnięcie',
'afterSales.resolveTitle': 'Decyzja',
'afterSales.adminNote': 'Notatka admina',
'afterSales.refundAmount': 'Kwota zwrotu',
'afterSales.confirmResolve': 'Potwierdź',
'afterSales.errorLoad': 'Nie udało się wczytać zgłoszeń.',
'afterSales.errorResolve': 'Nie udało się rozstrzygnąć.',
'afterSales.type.Withdrawal': 'Zwrot / odstąpienie',
'afterSales.type.Defect': 'Wada',
'afterSales.type.MissingPart': 'Brakująca część',
'afterSales.type.WrongItem': 'Zły produkt',
'afterSales.type.Other': 'Inne',
'afterSales.status.Submitted': 'Nowe',
'afterSales.status.InReview': 'W trakcie',
'afterSales.status.Approved': 'Zatwierdzone',
'afterSales.status.Rejected': 'Odrzucone',
'afterSales.status.Resolved': 'Zamknięte',
'afterSales.resolution.Refund': 'Zwrot (PayU)',
'afterSales.resolution.Replacement': 'Wymiana',
'afterSales.resolution.SendMissingPart': 'Wyślij brakującą część',
'afterSales.resolution.Reject': 'Odrzuć',
'afterSales.resolution.Other': 'Inne',
'nav.promotions': 'Promocje', 'nav.promotions': 'Promocje',
'nav.discounts': 'Promocje', 'nav.discounts': 'Promocje',
'nav.newsletter': 'Newsletter', 'nav.newsletter': 'Newsletter',
@@ -0,0 +1,285 @@
<div class="page-header">
<div>
<h1>{{ 'afterSales.title' | translate }}</h1>
<p class="page-purpose">{{ 'afterSales.purpose' | translate }}</p>
</div>
</div>
<div class="card" style="margin-bottom: 16px">
<div class="card-pad flex items-end gap-8" style="flex-wrap: wrap">
<div class="field" style="min-width: 180px">
<label for="as-status">{{ 'afterSales.filterStatus' | translate }}</label>
<select
id="as-status"
class="select"
[(ngModel)]="statusFilter"
name="statusFilter"
(ngModelChange)="handleFilter()"
>
<option value="">{{ 'common.all' | translate }}</option>
@for (s of statuses; track s) {
<option [ngValue]="s">
{{ 'afterSales.status.' + enumKey(s) | translate }}
</option>
}
</select>
</div>
</div>
</div>
@if (loading()) {
<app-admin-skeleton variant="table" [rows]="6" />
} @else {
<div class="card">
<div class="table-wrap">
<table class="data">
<thead>
<tr>
<th>{{ 'afterSales.colCreated' | translate }}</th>
<th>{{ 'afterSales.colOrder' | translate }}</th>
<th>{{ 'afterSales.colCustomer' | translate }}</th>
<th>{{ 'afterSales.colType' | translate }}</th>
<th>{{ 'orders.status' | translate }}</th>
<th></th>
</tr>
</thead>
<tbody>
@for (item of items(); track item.id) {
<tr>
<td>{{ item.createdAt | date: 'yyyy-MM-dd HH:mm' }}</td>
<td class="cell-strong">{{ item.orderNumber }}</td>
<td>
<div class="cell-strong">{{ item.customerName }}</div>
<div class="cell-muted">{{ item.customerEmail }}</div>
</td>
<td>{{ 'afterSales.type.' + enumKey(item.type) | translate }}</td>
<td>
<span class="badge" [class]="statusBadge(item.status)">
{{ 'afterSales.status.' + enumKey(item.status) | translate }}
</span>
</td>
<td>
<button
type="button"
class="btn btn-secondary btn-sm"
(click)="openDetail(item)"
>
{{ 'afterSales.open' | translate }}
</button>
</td>
</tr>
} @empty {
<tr>
<td colspan="6" class="cell-muted">
{{ 'afterSales.empty' | translate }}
</td>
</tr>
}
</tbody>
</table>
</div>
<div class="card-pad">
<app-admin-pagination
[page]="page()"
[pageSize]="pageSize"
[totalCount]="totalCount()"
(prev)="handlePage(page() - 1)"
(next)="handlePage(page() + 1)"
/>
</div>
</div>
}
@if (selected(); as d) {
<div
class="admin-modal"
role="dialog"
aria-modal="true"
(click)="closeDetail()"
>
<div
class="admin-modal__panel admin-modal__panel--lg"
(click)="$event.stopPropagation()"
>
<div class="admin-modal__head">
<div>
<h2>{{ d.orderNumber }}</h2>
<p class="page-purpose" style="margin: 0">
{{ 'afterSales.type.' + enumKey(d.type) | translate }}
·
<span class="badge" [class]="statusBadge(d.status)">
{{ 'afterSales.status.' + enumKey(d.status) | translate }}
</span>
</p>
</div>
<button
type="button"
class="admin-modal__close"
(click)="closeDetail()"
[attr.aria-label]="'common.close' | translate"
>
✕
</button>
</div>
<div class="admin-modal__body stack" style="gap: 16px">
<div class="grid grid-2">
<div>
<div class="cell-muted">{{ 'afterSales.colCustomer' | translate }}</div>
<div class="cell-strong">{{ d.customerName }}</div>
<div class="cell-muted">{{ d.customerEmail }}</div>
</div>
<div>
<div class="cell-muted">{{ 'afterSales.colCreated' | translate }}</div>
<div>{{ d.createdAt | date: 'yyyy-MM-dd HH:mm' }}</div>
</div>
</div>
<div class="card">
<div class="card-head">
<h2>{{ 'afterSales.lines' | translate }}</h2>
</div>
<div class="table-wrap">
<table class="data">
<thead>
<tr>
<th>{{ 'products.name' | translate }}</th>
<th>SKU</th>
<th>{{ 'orders.qty' | translate }}</th>
<th>{{ 'products.price' | translate }}</th>
</tr>
</thead>
<tbody>
@for (line of d.lines; track line.orderLineId) {
<tr>
<td class="cell-strong">{{ line.productName }}</td>
<td class="cell-muted">{{ line.sku }}</td>
<td>{{ line.quantity }}</td>
<td>{{ line.unitPrice | i18nCurrency }}</td>
</tr>
}
</tbody>
</table>
</div>
</div>
<div class="section-block" style="border: 0; padding: 0">
<h3>{{ 'afterSales.customerNote' | translate }}</h3>
<p style="margin: 0; white-space: pre-wrap">
{{ d.customerNote || '—' }}
</p>
</div>
@if (d.attachments.length) {
<div>
<h3 style="margin: 0 0 8px">{{ 'afterSales.photos' | translate }}</h3>
<div class="flex gap-8" style="flex-wrap: wrap">
@for (att of d.attachments; track att.id) {
<a [href]="imageUrl(att.url)" target="_blank" rel="noopener">
<img
[src]="imageUrl(att.url)"
[alt]="att.originalFileName || ''"
style="
width: 96px;
height: 96px;
object-fit: cover;
border-radius: 8px;
border: 1px solid var(--border);
"
/>
</a>
}
</div>
</div>
}
@if (d.resolution) {
<div class="section-block" style="border: 0; padding: 0">
<h3>{{ 'afterSales.resolution' | translate }}</h3>
<p style="margin: 0">
{{ 'afterSales.resolution.' + enumKey(d.resolution) | translate }}
@if (d.refundAmount != null) {
· {{ d.refundAmount | i18nCurrency }}
}
@if (d.payURefundId) {
· {{ d.payURefundId }}
}
</p>
@if (d.adminNote) {
<p class="cell-muted" style="margin: 8px 0 0; white-space: pre-wrap">
{{ d.adminNote }}
</p>
}
</div>
}
@if (isOpen(d)) {
<div class="card">
<div class="card-head">
<h2>{{ 'afterSales.resolveTitle' | translate }}</h2>
</div>
<div class="card-pad stack" style="gap: 12px">
<div class="field">
<label for="as-res">{{ 'afterSales.resolution' | translate }}</label>
<select
id="as-res"
class="select"
[(ngModel)]="resolution"
name="resolution"
>
<option [ngValue]="'refund'">
{{ 'afterSales.resolution.Refund' | translate }}
</option>
<option [ngValue]="'replacement'">
{{ 'afterSales.resolution.Replacement' | translate }}
</option>
<option [ngValue]="'sendMissingPart'">
{{ 'afterSales.resolution.SendMissingPart' | translate }}
</option>
<option [ngValue]="'other'">
{{ 'afterSales.resolution.Other' | translate }}
</option>
<option [ngValue]="'reject'">
{{ 'afterSales.resolution.Reject' | translate }}
</option>
</select>
</div>
@if (resolution === 'refund') {
<div class="field">
<label for="as-amt">{{ 'afterSales.refundAmount' | translate }}</label>
<input
id="as-amt"
class="input"
type="number"
step="0.01"
min="0.01"
[(ngModel)]="refundAmount"
name="refundAmount"
/>
</div>
}
<div class="field">
<label for="as-note">{{ 'afterSales.adminNote' | translate }}</label>
<textarea
id="as-note"
class="textarea"
rows="3"
[(ngModel)]="adminNote"
name="adminNote"
></textarea>
</div>
<button
type="button"
class="btn btn-primary"
[disabled]="resolving()"
(click)="handleResolve()"
>
{{ 'afterSales.confirmResolve' | translate }}
</button>
</div>
</div>
}
</div>
</div>
</div>
}
@@ -0,0 +1,177 @@
import {
ChangeDetectionStrategy,
Component,
inject,
OnInit,
signal,
} from '@angular/core';
import { DatePipe } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { I18nCurrencyPipe } from '../../core/i18n/i18n-currency.pipe';
import { TranslatePipe } from '../../core/i18n/translate.pipe';
import { ApiService } from '../../core/api.service';
import { resolveProductImageUrl } from '../../core/product-image.util';
import { AdminSkeletonComponent } from '../../shared/admin-skeleton.component';
import { AdminPaginationComponent } from '../../shared/admin-pagination.component';
import { ToastService } from '../../shared/toast.service';
import {
AFTER_SALES_STATUSES,
AdminAfterSalesList,
AfterSalesRequestDetail,
AfterSalesRequestStatus,
AfterSalesResolution,
afterSalesEnumKey,
} from './after-sales.models';
@Component({
selector: 'app-after-sales-admin',
standalone: true,
imports: [
FormsModule,
DatePipe,
TranslatePipe,
I18nCurrencyPipe,
AdminSkeletonComponent,
AdminPaginationComponent,
],
templateUrl: './after-sales-admin.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AfterSalesAdminComponent implements OnInit {
readonly #api = inject(ApiService);
readonly #toast = inject(ToastService);
readonly loading = signal(true);
readonly resolving = signal(false);
readonly items = signal<AfterSalesRequestDetail[]>([]);
readonly totalCount = signal(0);
readonly page = signal(1);
readonly pageSize = 20;
readonly selected = signal<AfterSalesRequestDetail | null>(null);
statusFilter: AfterSalesRequestStatus | '' = '';
readonly statuses = AFTER_SALES_STATUSES;
resolution: AfterSalesResolution = 'refund';
adminNote = '';
refundAmount: number | null = null;
readonly enumKey = afterSalesEnumKey;
imageUrl(url: string): string {
return resolveProductImageUrl(url);
}
ngOnInit(): void {
this.load();
}
load(): void {
this.loading.set(true);
const params = new URLSearchParams({
page: String(this.page()),
pageSize: String(this.pageSize),
});
if (this.statusFilter) {
params.set('status', this.statusFilter);
}
this.#api
.get<AdminAfterSalesList>(`/after-sales/admin?${params.toString()}`)
.subscribe({
next: (res) => {
this.items.set(res.items);
this.totalCount.set(res.totalCount);
this.loading.set(false);
},
error: () => {
this.loading.set(false);
this.#toast.error('afterSales.errorLoad');
},
});
}
handleFilter(): void {
this.page.set(1);
this.load();
}
handlePage(p: number): void {
this.page.set(p);
this.load();
}
openDetail(item: AfterSalesRequestDetail): void {
this.selected.set(item);
this.resolution = 'refund';
this.adminNote = '';
this.refundAmount = item.lines.reduce(
(sum, l) => sum + l.unitPrice * l.quantity,
0,
);
}
closeDetail(): void {
this.selected.set(null);
}
statusBadge(status: AfterSalesRequestStatus): string {
switch (status) {
case 'submitted':
case 'inReview':
return 'badge-warning';
case 'approved':
case 'resolved':
return 'badge-success';
case 'rejected':
return 'badge-danger';
default:
return 'badge-neutral';
}
}
isOpen(item: AfterSalesRequestDetail): boolean {
return (
item.status === 'submitted' ||
item.status === 'inReview' ||
item.status === 'approved'
);
}
handleResolve(): void {
const item = this.selected();
if (!item || this.resolving()) {
return;
}
this.resolving.set(true);
this.#api
.post<AfterSalesRequestDetail>(
`/after-sales/admin/${item.id}/resolve`,
{
resolution: this.resolution,
adminNote: this.adminNote || null,
refundAmount:
this.resolution === 'refund' ? this.refundAmount : null,
},
)
.subscribe({
next: (updated) => {
this.resolving.set(false);
this.selected.set(updated);
this.#toast.success('common.saved');
this.load();
},
error: (err) => {
this.resolving.set(false);
const msg =
err?.error?.message ||
err?.error?.title ||
'afterSales.errorResolve';
this.#toast.error(
typeof msg === 'string' ? msg : 'afterSales.errorResolve',
);
},
});
}
}
@@ -0,0 +1,73 @@
export type AfterSalesRequestType =
| 'withdrawal'
| 'defect'
| 'missingPart'
| 'wrongItem'
| 'other';
export type AfterSalesRequestStatus =
| 'submitted'
| 'inReview'
| 'approved'
| 'rejected'
| 'resolved';
export type AfterSalesResolution =
| 'refund'
| 'replacement'
| 'sendMissingPart'
| 'reject'
| 'other';
export interface AfterSalesRequestLine {
orderLineId: string;
productName: string;
sku: string;
quantity: number;
unitPrice: number;
}
export interface AfterSalesAttachment {
id: string;
url: string;
originalFileName: string | null;
}
export interface AfterSalesRequestDetail {
id: string;
orderId: string;
orderNumber: string;
customerEmail: string;
customerName: string;
type: AfterSalesRequestType;
status: AfterSalesRequestStatus;
customerNote: string;
adminNote: string | null;
resolution: AfterSalesResolution | null;
refundAmount: number | null;
payURefundId: string | null;
createdAt: string;
resolvedAt: string | null;
lines: AfterSalesRequestLine[];
attachments: AfterSalesAttachment[];
}
export interface AdminAfterSalesList {
items: AfterSalesRequestDetail[];
totalCount: number;
page: number;
pageSize: number;
}
export const AFTER_SALES_STATUSES: AfterSalesRequestStatus[] = [
'submitted',
'inReview',
'approved',
'rejected',
'resolved',
];
/** Map API camelCase enum to i18n PascalCase suffix. */
export function afterSalesEnumKey(value: string): string {
return value ? value.charAt(0).toUpperCase() + value.slice(1) : value;
}
@@ -56,6 +56,15 @@
<svg lucideShoppingCart [size]="18" class="ico" aria-hidden="true"></svg> <svg lucideShoppingCart [size]="18" class="ico" aria-hidden="true"></svg>
{{ 'nav.orders' | translate }} {{ 'nav.orders' | translate }}
</a> </a>
<a
routerLink="/after-sales"
routerLinkActive="is-active"
class="nav-link"
(click)="closeSidebar()"
>
<svg lucidePackage [size]="18" class="ico" aria-hidden="true"></svg>
{{ 'nav.afterSales' | translate }}
</a>
<a <a
routerLink="/inventory" routerLink="/inventory"
routerLinkActive="is-active" routerLinkActive="is-active"
@@ -0,0 +1,147 @@
using Asp.Versioning;
using iLED.Application.AfterSales;
using iLED.Domain.Enums;
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace iLED.Api.Controllers;
[ApiController]
[ApiVersion("1.0")]
[Route("api/v{version:apiVersion}/after-sales")]
public sealed class AfterSalesController(IMediator mediator) : ControllerBase
{
[HttpGet("orders/{orderId:guid}")]
[AllowAnonymous]
public async Task<IActionResult> GetOrder(
Guid orderId,
[FromQuery] string? token,
CancellationToken cancellationToken)
{
var result = await mediator.Send(
new GetAfterSalesOrderQuery(orderId, token),
cancellationToken);
return Ok(result);
}
[HttpPost("uploads")]
[AllowAnonymous]
[RequestSizeLimit(10 * 1024 * 1024)]
public async Task<IActionResult> Upload(
[FromForm] Guid orderId,
[FromForm] string? token,
IFormFile file,
CancellationToken cancellationToken)
{
if (file is null || file.Length == 0)
{
return BadRequest(new { message = "File required." });
}
await using var stream = file.OpenReadStream();
var result = await mediator.Send(
new UploadAfterSalesImageCommand(
orderId,
token,
stream,
file.FileName),
cancellationToken);
return Ok(result);
}
[HttpPost("requests")]
[AllowAnonymous]
public async Task<IActionResult> Create(
[FromBody] CreateAfterSalesRequestBody body,
CancellationToken cancellationToken)
{
var result = await mediator.Send(
new CreateAfterSalesRequestCommand(
body.OrderId,
body.Token,
body.Type,
body.CustomerNote ?? string.Empty,
body.Lines ?? [],
body.AttachmentUrls ?? []),
cancellationToken);
return Ok(result);
}
[HttpGet("requests/mine")]
[Authorize]
public async Task<IActionResult> GetMine(CancellationToken cancellationToken)
{
var result = await mediator.Send(
new GetMyAfterSalesRequestsQuery(),
cancellationToken);
return Ok(result);
}
[HttpGet("admin")]
[Authorize(Roles = "Admin")]
public async Task<IActionResult> AdminList(
[FromQuery] AfterSalesRequestStatus? status,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 30,
CancellationToken cancellationToken = default)
{
var result = await mediator.Send(
new GetAdminAfterSalesQuery(status, page, pageSize),
cancellationToken);
return Ok(result);
}
[HttpGet("admin/{id:guid}")]
[Authorize(Roles = "Admin")]
public async Task<IActionResult> AdminDetail(
Guid id,
CancellationToken cancellationToken)
{
var result = await mediator.Send(
new GetAdminAfterSalesDetailQuery(id),
cancellationToken);
return Ok(result);
}
[HttpPost("admin/{id:guid}/resolve")]
[Authorize(Roles = "Admin")]
public async Task<IActionResult> Resolve(
Guid id,
[FromBody] ResolveAfterSalesBody body,
CancellationToken cancellationToken)
{
var result = await mediator.Send(
new ResolveAfterSalesRequestCommand(
id,
body.Resolution,
body.AdminNote,
body.RefundAmount),
cancellationToken);
return Ok(result);
}
}
public sealed class CreateAfterSalesRequestBody
{
public Guid OrderId { get; set; }
public string? Token { get; set; }
public AfterSalesRequestType Type { get; set; }
public string? CustomerNote { get; set; }
public List<AfterSalesLineInput>? Lines { get; set; }
public List<string>? AttachmentUrls { get; set; }
}
public sealed class ResolveAfterSalesBody
{
public AfterSalesResolution Resolution { get; set; }
public string? AdminNote { get; set; }
public decimal? RefundAmount { get; set; }
}
@@ -0,0 +1,562 @@
using iLED.Application.Common.Exceptions;
using iLED.Application.Common.Interfaces;
using iLED.Domain.AfterSales;
using iLED.Domain.Enums;
using MediatR;
using Microsoft.EntityFrameworkCore;
namespace iLED.Application.AfterSales;
public static class AfterSalesRules
{
public static readonly TimeSpan WithdrawalWindow = TimeSpan.FromDays(14);
public static readonly TimeSpan ClaimWindow = TimeSpan.FromDays(365);
public const int MaxAttachments = 5;
public static readonly OrderStatus[] EligibleStatuses =
[
OrderStatus.Paid,
OrderStatus.Processing,
OrderStatus.Shipped,
OrderStatus.Delivered,
];
public static readonly AfterSalesRequestStatus[] OpenStatuses =
[
AfterSalesRequestStatus.Submitted,
AfterSalesRequestStatus.InReview,
AfterSalesRequestStatus.Approved,
];
}
public sealed record AfterSalesLineInput(Guid OrderLineId, int Quantity);
public sealed record CreateAfterSalesRequestCommand(
Guid OrderId,
string? AccessToken,
AfterSalesRequestType Type,
string CustomerNote,
IReadOnlyList<AfterSalesLineInput> Lines,
IReadOnlyList<string> AttachmentUrls) : IRequest<AfterSalesRequestDto>;
public sealed record UploadAfterSalesImageCommand(
Guid OrderId,
string? AccessToken,
Stream ImageStream,
string FileName) : IRequest<AfterSalesUploadResult>;
public sealed record AfterSalesUploadResult(string Url);
public sealed record GetAfterSalesOrderQuery(
Guid OrderId,
string? AccessToken) : IRequest<AfterSalesOrderDto>;
public sealed record GetMyAfterSalesRequestsQuery
: IRequest<IReadOnlyList<AfterSalesRequestSummaryDto>>;
public sealed record GetAdminAfterSalesQuery(
AfterSalesRequestStatus? Status = null,
int Page = 1,
int PageSize = 30) : IRequest<AdminAfterSalesListDto>;
public sealed record GetAdminAfterSalesDetailQuery(Guid Id)
: IRequest<AfterSalesRequestDetailDto>;
public sealed record ResolveAfterSalesRequestCommand(
Guid Id,
AfterSalesResolution Resolution,
string? AdminNote,
decimal? RefundAmount) : IRequest<AfterSalesRequestDetailDto>;
public sealed record AfterSalesOrderLineDto(
Guid OrderLineId,
string ProductName,
string VariantColor,
string Sku,
string? ImageUrl,
decimal UnitPrice,
int Quantity,
int RemainingClaimable);
public sealed record AfterSalesOrderDto(
Guid OrderId,
string OrderNumber,
OrderStatus Status,
DateTime CreatedAt,
IReadOnlyList<AfterSalesOrderLineDto> Lines);
public sealed record AfterSalesRequestLineDto(
Guid OrderLineId,
string ProductName,
string Sku,
int Quantity,
decimal UnitPrice);
public sealed record AfterSalesAttachmentDto(
Guid Id,
string Url,
string? OriginalFileName);
public sealed record AfterSalesRequestDto(
Guid Id,
Guid OrderId,
string OrderNumber,
AfterSalesRequestType Type,
AfterSalesRequestStatus Status,
string CustomerNote,
DateTime CreatedAt);
public sealed record AfterSalesRequestSummaryDto(
Guid Id,
Guid OrderId,
string OrderNumber,
AfterSalesRequestType Type,
AfterSalesRequestStatus Status,
AfterSalesResolution? Resolution,
DateTime CreatedAt);
public sealed record AfterSalesRequestDetailDto(
Guid Id,
Guid OrderId,
string OrderNumber,
string CustomerEmail,
string CustomerName,
AfterSalesRequestType Type,
AfterSalesRequestStatus Status,
string CustomerNote,
string? AdminNote,
AfterSalesResolution? Resolution,
decimal? RefundAmount,
string? PayURefundId,
DateTime CreatedAt,
DateTime? ResolvedAt,
IReadOnlyList<AfterSalesRequestLineDto> Lines,
IReadOnlyList<AfterSalesAttachmentDto> Attachments);
public sealed record AdminAfterSalesListDto(
IReadOnlyList<AfterSalesRequestDetailDto> Items,
int TotalCount,
int Page,
int PageSize);
public sealed class GetAfterSalesOrderQueryHandler(
IAppDbContext db,
IAfterSalesAccessService access)
: IRequestHandler<GetAfterSalesOrderQuery, AfterSalesOrderDto>
{
public async Task<AfterSalesOrderDto> Handle(
GetAfterSalesOrderQuery request,
CancellationToken cancellationToken)
{
var order = await db.Orders
.AsNoTracking()
.Include(o => o.Lines)
.Include(o => o.AfterSalesRequests)
.ThenInclude(r => r.Lines)
.FirstOrDefaultAsync(o => o.Id == request.OrderId, cancellationToken)
?? throw new NotFoundException("Order not found.");
if (!access.CanAccessOrder(order, request.AccessToken))
{
throw new ValidationException("Access denied.");
}
var claimed = order.AfterSalesRequests
.Where(r => AfterSalesRules.OpenStatuses.Contains(r.Status) ||
r.Status == AfterSalesRequestStatus.Resolved)
.SelectMany(r => r.Lines)
.GroupBy(l => l.OrderLineId)
.ToDictionary(g => g.Key, g => g.Sum(x => x.Quantity));
var lines = order.Lines
.OrderBy(l => l.CreatedAt)
.Select(l =>
{
claimed.TryGetValue(l.Id, out var used);
return new AfterSalesOrderLineDto(
l.Id,
l.ProductName,
l.VariantColor,
l.Sku,
l.ImageUrl,
l.UnitPrice,
l.Quantity,
Math.Max(0, l.Quantity - used));
})
.ToList();
return new AfterSalesOrderDto(
order.Id,
order.OrderNumber,
order.Status,
order.CreatedAt,
lines);
}
}
public sealed class UploadAfterSalesImageCommandHandler(
IAppDbContext db,
IAfterSalesAccessService access,
IAfterSalesImageStorage storage)
: IRequestHandler<UploadAfterSalesImageCommand, AfterSalesUploadResult>
{
public async Task<AfterSalesUploadResult> Handle(
UploadAfterSalesImageCommand request,
CancellationToken cancellationToken)
{
var order = await db.Orders
.FirstOrDefaultAsync(o => o.Id == request.OrderId, cancellationToken)
?? throw new NotFoundException("Order not found.");
if (!access.CanAccessOrder(order, request.AccessToken))
{
throw new ValidationException("Access denied.");
}
var url = await storage.SaveAsWebpAsync(
request.ImageStream,
request.FileName,
cancellationToken);
return new AfterSalesUploadResult(url);
}
}
public sealed class CreateAfterSalesRequestCommandHandler(
IAppDbContext db,
IAfterSalesAccessService access,
ICurrentUserService currentUser)
: IRequestHandler<CreateAfterSalesRequestCommand, AfterSalesRequestDto>
{
public async Task<AfterSalesRequestDto> Handle(
CreateAfterSalesRequestCommand request,
CancellationToken cancellationToken)
{
var order = await db.Orders
.Include(o => o.Lines)
.Include(o => o.AfterSalesRequests)
.ThenInclude(r => r.Lines)
.FirstOrDefaultAsync(o => o.Id == request.OrderId, cancellationToken)
?? throw new NotFoundException("Order not found.");
if (!access.CanAccessOrder(order, request.AccessToken))
{
throw new ValidationException("Access denied.");
}
if (!AfterSalesRules.EligibleStatuses.Contains(order.Status))
{
throw new ValidationException(
"After-sales is available after the order is paid.");
}
var age = DateTime.UtcNow - order.CreatedAt;
var window = request.Type == AfterSalesRequestType.Withdrawal
? AfterSalesRules.WithdrawalWindow
: AfterSalesRules.ClaimWindow;
if (age > window)
{
throw new ValidationException(
"The claim window for this request type has expired.");
}
if (request.Lines is null || request.Lines.Count == 0)
{
throw new ValidationException("Select at least one item.");
}
var attachments = (request.AttachmentUrls ?? [])
.Where(u => !string.IsNullOrWhiteSpace(u))
.Distinct(StringComparer.OrdinalIgnoreCase)
.Take(AfterSalesRules.MaxAttachments)
.ToList();
var claimed = order.AfterSalesRequests
.Where(r => AfterSalesRules.OpenStatuses.Contains(r.Status) ||
r.Status == AfterSalesRequestStatus.Resolved)
.SelectMany(r => r.Lines)
.GroupBy(l => l.OrderLineId)
.ToDictionary(g => g.Key, g => g.Sum(x => x.Quantity));
var entity = new AfterSalesRequest
{
OrderId = order.Id,
UserId = currentUser.UserId ?? order.UserId,
Type = request.Type,
Status = AfterSalesRequestStatus.Submitted,
CustomerNote = (request.CustomerNote ?? string.Empty).Trim(),
};
foreach (var line in request.Lines)
{
var orderLine = order.Lines.FirstOrDefault(l => l.Id == line.OrderLineId)
?? throw new ValidationException("Invalid order line.");
claimed.TryGetValue(orderLine.Id, out var used);
var remaining = orderLine.Quantity - used;
if (line.Quantity < 1 || line.Quantity > remaining)
{
throw new ValidationException(
$"Invalid quantity for {orderLine.ProductName}.");
}
entity.Lines.Add(new AfterSalesRequestLine
{
OrderLineId = orderLine.Id,
Quantity = line.Quantity,
});
}
var sort = 0;
foreach (var url in attachments)
{
if (!url.StartsWith("/uploads/after-sales/", StringComparison.Ordinal))
{
throw new ValidationException("Invalid attachment URL.");
}
entity.Attachments.Add(new AfterSalesAttachment
{
Url = url,
SortOrder = sort++,
});
}
db.Add(entity);
await db.SaveChangesAsync(cancellationToken);
return new AfterSalesRequestDto(
entity.Id,
order.Id,
order.OrderNumber,
entity.Type,
entity.Status,
entity.CustomerNote,
entity.CreatedAt);
}
}
public sealed class GetMyAfterSalesRequestsQueryHandler(
IAppDbContext db,
ICurrentUserService currentUser)
: IRequestHandler<GetMyAfterSalesRequestsQuery, IReadOnlyList<AfterSalesRequestSummaryDto>>
{
public async Task<IReadOnlyList<AfterSalesRequestSummaryDto>> Handle(
GetMyAfterSalesRequestsQuery request,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(currentUser.UserId))
{
return [];
}
return await db.AfterSalesRequests
.AsNoTracking()
.Include(r => r.Order)
.Where(r => r.UserId == currentUser.UserId ||
r.Order.UserId == currentUser.UserId)
.OrderByDescending(r => r.CreatedAt)
.Select(r => new AfterSalesRequestSummaryDto(
r.Id,
r.OrderId,
r.Order.OrderNumber,
r.Type,
r.Status,
r.Resolution,
r.CreatedAt))
.ToListAsync(cancellationToken);
}
}
public sealed class GetAdminAfterSalesQueryHandler(IAppDbContext db)
: IRequestHandler<GetAdminAfterSalesQuery, AdminAfterSalesListDto>
{
public async Task<AdminAfterSalesListDto> Handle(
GetAdminAfterSalesQuery request,
CancellationToken cancellationToken)
{
var page = Math.Max(1, request.Page);
var pageSize = Math.Clamp(request.PageSize, 1, 100);
var query = db.AfterSalesRequests
.AsNoTracking()
.Include(r => r.Order)
.Include(r => r.Lines)
.ThenInclude(l => l.OrderLine)
.Include(r => r.Attachments)
.AsQueryable();
if (request.Status is { } status)
{
query = query.Where(r => r.Status == status);
}
var total = await query.CountAsync(cancellationToken);
var items = await query
.OrderByDescending(r => r.CreatedAt)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync(cancellationToken);
return new AdminAfterSalesListDto(
items.Select(MapDetail).ToList(),
total,
page,
pageSize);
}
internal static AfterSalesRequestDetailDto MapDetail(AfterSalesRequest r) =>
new(
r.Id,
r.OrderId,
r.Order.OrderNumber,
r.Order.CustomerEmail,
$"{r.Order.CustomerFirstName} {r.Order.CustomerLastName}".Trim(),
r.Type,
r.Status,
r.CustomerNote,
r.AdminNote,
r.Resolution,
r.RefundAmount,
r.PayURefundId,
r.CreatedAt,
r.ResolvedAt,
r.Lines.Select(l => new AfterSalesRequestLineDto(
l.OrderLineId,
l.OrderLine.ProductName,
l.OrderLine.Sku,
l.Quantity,
l.OrderLine.UnitPrice)).ToList(),
r.Attachments
.OrderBy(a => a.SortOrder)
.Select(a => new AfterSalesAttachmentDto(
a.Id,
a.Url,
a.OriginalFileName))
.ToList());
}
public sealed class GetAdminAfterSalesDetailQueryHandler(IAppDbContext db)
: IRequestHandler<GetAdminAfterSalesDetailQuery, AfterSalesRequestDetailDto>
{
public async Task<AfterSalesRequestDetailDto> Handle(
GetAdminAfterSalesDetailQuery request,
CancellationToken cancellationToken)
{
var entity = await db.AfterSalesRequests
.AsNoTracking()
.Include(r => r.Order)
.Include(r => r.Lines)
.ThenInclude(l => l.OrderLine)
.Include(r => r.Attachments)
.FirstOrDefaultAsync(r => r.Id == request.Id, cancellationToken)
?? throw new NotFoundException("After-sales request not found.");
return GetAdminAfterSalesQueryHandler.MapDetail(entity);
}
}
public sealed class ResolveAfterSalesRequestCommandHandler(
IAppDbContext db,
IPaymentService payments,
ICurrentUserService currentUser)
: IRequestHandler<ResolveAfterSalesRequestCommand, AfterSalesRequestDetailDto>
{
public async Task<AfterSalesRequestDetailDto> Handle(
ResolveAfterSalesRequestCommand request,
CancellationToken cancellationToken)
{
var entity = await db.AfterSalesRequests
.Include(r => r.Order)
.ThenInclude(o => o.Payment)
.Include(r => r.Lines)
.ThenInclude(l => l.OrderLine)
.Include(r => r.Attachments)
.FirstOrDefaultAsync(r => r.Id == request.Id, cancellationToken)
?? throw new NotFoundException("After-sales request not found.");
if (entity.Status is AfterSalesRequestStatus.Resolved
or AfterSalesRequestStatus.Rejected)
{
throw new ValidationException("Request is already closed.");
}
entity.AdminNote = request.AdminNote?.Trim();
entity.Resolution = request.Resolution;
entity.ResolvedByAdminId = currentUser.UserId;
entity.ResolvedAt = DateTime.UtcNow;
entity.UpdatedAt = DateTime.UtcNow;
if (request.Resolution == AfterSalesResolution.Reject)
{
if (string.IsNullOrWhiteSpace(entity.AdminNote))
{
throw new ValidationException("Admin note is required to reject.");
}
entity.Status = AfterSalesRequestStatus.Rejected;
await db.SaveChangesAsync(cancellationToken);
return GetAdminAfterSalesQueryHandler.MapDetail(entity);
}
if (request.Resolution == AfterSalesResolution.Refund)
{
var defaultAmount = entity.Lines.Sum(
l => l.OrderLine.UnitPrice * l.Quantity);
var amount = request.RefundAmount ?? defaultAmount;
if (amount <= 0 || amount > entity.Order.Total)
{
throw new ValidationException("Invalid refund amount.");
}
var payment = entity.Order.Payment;
if (payment is null ||
string.IsNullOrWhiteSpace(payment.ExternalId))
{
throw new ValidationException(
"No PayU payment found for this order.");
}
if (entity.Order.PaymentMethod != PaymentMethod.PayU)
{
throw new ValidationException(
"Automated refunds are only available for PayU orders.");
}
var refund = await payments.RefundPaymentAsync(
new PaymentRefundRequest(
payment.ExternalId,
amount,
$"After-sales {entity.Id} · {entity.Order.OrderNumber}"),
cancellationToken);
if (!refund.Success)
{
throw new ValidationException(
refund.ErrorMessage ?? "PayU refund failed.");
}
entity.RefundAmount = amount;
entity.PayURefundId = refund.RefundId;
entity.Status = AfterSalesRequestStatus.Resolved;
var fullyRefunded = amount >= entity.Order.Total;
payment.Status = fullyRefunded
? PaymentStatus.Refunded
: payment.Status;
payment.UpdatedAt = DateTime.UtcNow;
if (fullyRefunded)
{
entity.Order.Status = OrderStatus.Refunded;
entity.Order.UpdatedAt = DateTime.UtcNow;
}
await db.SaveChangesAsync(cancellationToken);
return GetAdminAfterSalesQueryHandler.MapDetail(entity);
}
entity.Status = AfterSalesRequestStatus.Resolved;
await db.SaveChangesAsync(cancellationToken);
return GetAdminAfterSalesQueryHandler.MapDetail(entity);
}
}
@@ -0,0 +1,21 @@
using iLED.Domain.Orders;
namespace iLED.Application.Common.Interfaces;
public interface IAfterSalesAccessService
{
/// <summary>Creates a plain token, stores hash+expiry on the order.</summary>
string IssueAccessToken(Order order, TimeSpan? lifetime = null);
bool ValidateAccessToken(Order order, string? plainToken);
bool CanAccessOrder(Order order, string? plainToken);
}
public interface IAfterSalesEmailService
{
Task SendAccessLinkAsync(
Order order,
string plainToken,
CancellationToken cancellationToken = default);
}
@@ -0,0 +1,9 @@
namespace iLED.Application.Common.Interfaces;
public interface IAfterSalesImageStorage
{
Task<string> SaveAsWebpAsync(
Stream input,
string originalFileName,
CancellationToken cancellationToken);
}
@@ -28,6 +28,12 @@ public interface IAppDbContext : IApplicationDbContext
IQueryable<Domain.Orders.Order> Orders { get; } IQueryable<Domain.Orders.Order> Orders { get; }
IQueryable<Domain.Orders.OrderLine> OrderLines { get; }
IQueryable<Domain.AfterSales.AfterSalesRequest> AfterSalesRequests { get; }
IQueryable<Domain.AfterSales.AfterSalesAttachment> AfterSalesAttachments { get; }
IQueryable<Domain.Payments.Payment> Payments { get; } IQueryable<Domain.Payments.Payment> Payments { get; }
IQueryable<Domain.Shipping.Shipment> Shipments { get; } IQueryable<Domain.Shipping.Shipment> Shipments { get; }
@@ -17,6 +17,13 @@ public interface IPaymentService
Task<PaymentStatusResult> GetPaymentStatusAsync( Task<PaymentStatusResult> GetPaymentStatusAsync(
string externalId, string externalId,
CancellationToken cancellationToken = default); CancellationToken cancellationToken = default);
/// <summary>
/// Refunds a PayU order (full or partial amount in PLN).
/// </summary>
Task<PaymentRefundResult> RefundPaymentAsync(
PaymentRefundRequest request,
CancellationToken cancellationToken = default);
} }
public sealed record PaymentInitiationRequest( public sealed record PaymentInitiationRequest(
@@ -49,3 +56,13 @@ public sealed record PaymentStatusResult(
bool IsCompleted, bool IsCompleted,
bool IsCanceled, bool IsCanceled,
string? ErrorMessage); string? ErrorMessage);
public sealed record PaymentRefundRequest(
string ExternalId,
decimal Amount,
string Description);
public sealed record PaymentRefundResult(
bool Success,
string? RefundId,
string? ErrorMessage);
@@ -0,0 +1,16 @@
using iLED.Domain.Common;
namespace iLED.Domain.AfterSales;
public class AfterSalesAttachment : BaseEntity
{
public Guid AfterSalesRequestId { get; set; }
public AfterSalesRequest AfterSalesRequest { get; set; } = null!;
public string Url { get; set; } = string.Empty;
public string? OriginalFileName { get; set; }
public int SortOrder { get; set; }
}
@@ -0,0 +1,37 @@
using iLED.Domain.Common;
using iLED.Domain.Enums;
using iLED.Domain.Orders;
namespace iLED.Domain.AfterSales;
public class AfterSalesRequest : BaseEntity
{
public Guid OrderId { get; set; }
public Order Order { get; set; } = null!;
public string? UserId { get; set; }
public AfterSalesRequestType Type { get; set; }
public AfterSalesRequestStatus Status { get; set; } =
AfterSalesRequestStatus.Submitted;
public string CustomerNote { get; set; } = string.Empty;
public string? AdminNote { get; set; }
public AfterSalesResolution? Resolution { get; set; }
public decimal? RefundAmount { get; set; }
public string? PayURefundId { get; set; }
public DateTime? ResolvedAt { get; set; }
public string? ResolvedByAdminId { get; set; }
public ICollection<AfterSalesRequestLine> Lines { get; set; } = [];
public ICollection<AfterSalesAttachment> Attachments { get; set; } = [];
}
@@ -0,0 +1,17 @@
using iLED.Domain.Common;
using iLED.Domain.Orders;
namespace iLED.Domain.AfterSales;
public class AfterSalesRequestLine : BaseEntity
{
public Guid AfterSalesRequestId { get; set; }
public AfterSalesRequest AfterSalesRequest { get; set; } = null!;
public Guid OrderLineId { get; set; }
public OrderLine OrderLine { get; set; } = null!;
public int Quantity { get; set; }
}
@@ -0,0 +1,28 @@
namespace iLED.Domain.Enums;
public enum AfterSalesRequestType
{
Withdrawal = 0,
Defect = 1,
MissingPart = 2,
WrongItem = 3,
Other = 4,
}
public enum AfterSalesRequestStatus
{
Submitted = 0,
InReview = 1,
Approved = 2,
Rejected = 3,
Resolved = 4,
}
public enum AfterSalesResolution
{
Refund = 0,
Replacement = 1,
SendMissingPart = 2,
Reject = 3,
Other = 4,
}
@@ -74,8 +74,18 @@ public class Order : AuditableEntity
public Marketing.DiscountCode? DiscountCode { get; set; } public Marketing.DiscountCode? DiscountCode { get; set; }
/// <summary>
/// SHA-256 hash of guest after-sales access token (plain token emailed).
/// </summary>
public string? AfterSalesAccessTokenHash { get; set; }
public DateTime? AfterSalesAccessTokenExpiresAt { get; set; }
public ICollection<OrderLine> Lines { get; set; } = []; public ICollection<OrderLine> Lines { get; set; } = [];
public ICollection<AfterSales.AfterSalesRequest> AfterSalesRequests { get; set; } =
[];
public Payments.Payment? Payment { get; set; } public Payments.Payment? Payment { get; set; }
public Shipping.Shipment? Shipment { get; set; } public Shipping.Shipment? Shipment { get; set; }
@@ -69,6 +69,11 @@ public static class DependencyInjection
services.Configure<ProductImageStorageOptions>( services.Configure<ProductImageStorageOptions>(
configuration.GetSection(ProductImageStorageOptions.SectionName)); configuration.GetSection(ProductImageStorageOptions.SectionName));
services.AddScoped<IProductImageStorage, ProductImageStorage>(); services.AddScoped<IProductImageStorage, ProductImageStorage>();
services.Configure<AfterSalesImageStorageOptions>(
configuration.GetSection(AfterSalesImageStorageOptions.SectionName));
services.AddScoped<IAfterSalesImageStorage, AfterSalesImageStorage>();
services.AddScoped<IAfterSalesAccessService, AfterSalesAccessService>();
services.AddScoped<IAfterSalesEmailService, AfterSalesEmailService>();
services.AddStackExchangeRedisCache(options => services.AddStackExchangeRedisCache(options =>
{ {
@@ -357,6 +357,124 @@ namespace iLED.Infrastructure.Migrations
b.ToTable("StoreSettings"); b.ToTable("StoreSettings");
}); });
modelBuilder.Entity("iLED.Domain.AfterSales.AfterSalesAttachment", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("AfterSalesRequestId")
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("OriginalFileName")
.HasColumnType("text");
b.Property<int>("SortOrder")
.HasColumnType("integer");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Url")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("AfterSalesRequestId");
b.ToTable("AfterSalesAttachments");
});
modelBuilder.Entity("iLED.Domain.AfterSales.AfterSalesRequest", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("AdminNote")
.HasColumnType("text");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("CustomerNote")
.IsRequired()
.HasColumnType("text");
b.Property<Guid>("OrderId")
.HasColumnType("uuid");
b.Property<string>("PayURefundId")
.HasColumnType("text");
b.Property<decimal?>("RefundAmount")
.HasPrecision(18, 2)
.HasColumnType("numeric(18,2)");
b.Property<int?>("Resolution")
.HasColumnType("integer");
b.Property<DateTime?>("ResolvedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("ResolvedByAdminId")
.HasColumnType("text");
b.Property<int>("Status")
.HasColumnType("integer");
b.Property<int>("Type")
.HasColumnType("integer");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("UserId")
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("OrderId");
b.HasIndex("Status");
b.ToTable("AfterSalesRequests");
});
modelBuilder.Entity("iLED.Domain.AfterSales.AfterSalesRequestLine", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("AfterSalesRequestId")
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("OrderLineId")
.HasColumnType("uuid");
b.Property<int>("Quantity")
.HasColumnType("integer");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("AfterSalesRequestId");
b.HasIndex("OrderLineId");
b.ToTable("AfterSalesRequestLines");
});
modelBuilder.Entity("iLED.Domain.Cart.Cart", b => modelBuilder.Entity("iLED.Domain.Cart.Cart", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
@@ -1306,6 +1424,12 @@ namespace iLED.Infrastructure.Migrations
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasColumnType("uuid"); .HasColumnType("uuid");
b.Property<DateTime?>("AfterSalesAccessTokenExpiresAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("AfterSalesAccessTokenHash")
.HasColumnType("text");
b.Property<string>("BillingCity") b.Property<string>("BillingCity")
.HasColumnType("text"); .HasColumnType("text");
@@ -1647,6 +1771,47 @@ namespace iLED.Infrastructure.Migrations
.IsRequired(); .IsRequired();
}); });
modelBuilder.Entity("iLED.Domain.AfterSales.AfterSalesAttachment", b =>
{
b.HasOne("iLED.Domain.AfterSales.AfterSalesRequest", "AfterSalesRequest")
.WithMany("Attachments")
.HasForeignKey("AfterSalesRequestId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("AfterSalesRequest");
});
modelBuilder.Entity("iLED.Domain.AfterSales.AfterSalesRequest", b =>
{
b.HasOne("iLED.Domain.Orders.Order", "Order")
.WithMany("AfterSalesRequests")
.HasForeignKey("OrderId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Order");
});
modelBuilder.Entity("iLED.Domain.AfterSales.AfterSalesRequestLine", b =>
{
b.HasOne("iLED.Domain.AfterSales.AfterSalesRequest", "AfterSalesRequest")
.WithMany("Lines")
.HasForeignKey("AfterSalesRequestId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("iLED.Domain.Orders.OrderLine", "OrderLine")
.WithMany()
.HasForeignKey("OrderLineId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("AfterSalesRequest");
b.Navigation("OrderLine");
});
modelBuilder.Entity("iLED.Domain.Cart.CartItem", b => modelBuilder.Entity("iLED.Domain.Cart.CartItem", b =>
{ {
b.HasOne("iLED.Domain.Cart.Cart", "Cart") b.HasOne("iLED.Domain.Cart.Cart", "Cart")
@@ -2147,6 +2312,13 @@ namespace iLED.Infrastructure.Migrations
b.Navigation("Order"); b.Navigation("Order");
}); });
modelBuilder.Entity("iLED.Domain.AfterSales.AfterSalesRequest", b =>
{
b.Navigation("Attachments");
b.Navigation("Lines");
});
modelBuilder.Entity("iLED.Domain.Cart.Cart", b => modelBuilder.Entity("iLED.Domain.Cart.Cart", b =>
{ {
b.Navigation("Items"); b.Navigation("Items");
@@ -2214,6 +2386,8 @@ namespace iLED.Infrastructure.Migrations
modelBuilder.Entity("iLED.Domain.Orders.Order", b => modelBuilder.Entity("iLED.Domain.Orders.Order", b =>
{ {
b.Navigation("AfterSalesRequests");
b.Navigation("Lines"); b.Navigation("Lines");
b.Navigation("Payment"); b.Navigation("Payment");
@@ -1,4 +1,5 @@
using iLED.Domain.Admin; using iLED.Domain.Admin;
using iLED.Domain.AfterSales;
using iLED.Domain.Cart; using iLED.Domain.Cart;
using iLED.Domain.Catalog; using iLED.Domain.Catalog;
using iLED.Domain.Identity; using iLED.Domain.Identity;
@@ -48,6 +49,14 @@ public class ApplicationDbContext(DbContextOptions<ApplicationDbContext> options
public DbSet<OrderLine> OrderLines => Set<OrderLine>(); public DbSet<OrderLine> OrderLines => Set<OrderLine>();
public DbSet<AfterSalesRequest> AfterSalesRequests => Set<AfterSalesRequest>();
public DbSet<AfterSalesRequestLine> AfterSalesRequestLines =>
Set<AfterSalesRequestLine>();
public DbSet<AfterSalesAttachment> AfterSalesAttachments =>
Set<AfterSalesAttachment>();
public DbSet<Payment> Payments => Set<Payment>(); public DbSet<Payment> Payments => Set<Payment>();
public DbSet<Shipment> Shipments => Set<Shipment>(); public DbSet<Shipment> Shipments => Set<Shipment>();
@@ -117,6 +126,17 @@ public class ApplicationDbContext(DbContextOptions<ApplicationDbContext> options
IQueryable<Order> Application.Common.Interfaces.IAppDbContext.Orders => IQueryable<Order> Application.Common.Interfaces.IAppDbContext.Orders =>
Orders; Orders;
IQueryable<OrderLine> Application.Common.Interfaces.IAppDbContext.OrderLines =>
OrderLines;
IQueryable<AfterSalesRequest>
Application.Common.Interfaces.IAppDbContext.AfterSalesRequests =>
AfterSalesRequests;
IQueryable<AfterSalesAttachment>
Application.Common.Interfaces.IAppDbContext.AfterSalesAttachments =>
AfterSalesAttachments;
IQueryable<Payment> Application.Common.Interfaces.IAppDbContext.Payments => IQueryable<Payment> Application.Common.Interfaces.IAppDbContext.Payments =>
Payments; Payments;
@@ -287,5 +307,39 @@ public class ApplicationDbContext(DbContextOptions<ApplicationDbContext> options
.WithOne(o => o.Shipment) .WithOne(o => o.Shipment)
.HasForeignKey<Shipment>(s => s.OrderId) .HasForeignKey<Shipment>(s => s.OrderId)
.OnDelete(DeleteBehavior.Cascade); .OnDelete(DeleteBehavior.Cascade);
builder.Entity<AfterSalesRequest>()
.HasOne(r => r.Order)
.WithMany(o => o.AfterSalesRequests)
.HasForeignKey(r => r.OrderId)
.OnDelete(DeleteBehavior.Cascade);
builder.Entity<AfterSalesRequest>()
.HasIndex(r => r.Status);
builder.Entity<AfterSalesRequest>()
.HasIndex(r => r.OrderId);
builder.Entity<AfterSalesRequest>()
.Property(r => r.RefundAmount)
.HasPrecision(18, 2);
builder.Entity<AfterSalesRequestLine>()
.HasOne(l => l.AfterSalesRequest)
.WithMany(r => r.Lines)
.HasForeignKey(l => l.AfterSalesRequestId)
.OnDelete(DeleteBehavior.Cascade);
builder.Entity<AfterSalesRequestLine>()
.HasOne(l => l.OrderLine)
.WithMany()
.HasForeignKey(l => l.OrderLineId)
.OnDelete(DeleteBehavior.Restrict);
builder.Entity<AfterSalesAttachment>()
.HasOne(a => a.AfterSalesRequest)
.WithMany(r => r.Attachments)
.HasForeignKey(a => a.AfterSalesRequestId)
.OnDelete(DeleteBehavior.Cascade);
} }
} }
@@ -0,0 +1,154 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace iLED.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AfterSalesRequests : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<DateTime>(
name: "AfterSalesAccessTokenExpiresAt",
table: "Orders",
type: "timestamp with time zone",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "AfterSalesAccessTokenHash",
table: "Orders",
type: "text",
nullable: true);
migrationBuilder.CreateTable(
name: "AfterSalesRequests",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
OrderId = table.Column<Guid>(type: "uuid", nullable: false),
UserId = table.Column<string>(type: "text", nullable: true),
Type = table.Column<int>(type: "integer", nullable: false),
Status = table.Column<int>(type: "integer", nullable: false),
CustomerNote = table.Column<string>(type: "text", nullable: false),
AdminNote = table.Column<string>(type: "text", nullable: true),
Resolution = table.Column<int>(type: "integer", nullable: true),
RefundAmount = table.Column<decimal>(type: "numeric(18,2)", precision: 18, scale: 2, nullable: true),
PayURefundId = table.Column<string>(type: "text", nullable: true),
ResolvedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
ResolvedByAdminId = table.Column<string>(type: "text", nullable: true),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_AfterSalesRequests", x => x.Id);
table.ForeignKey(
name: "FK_AfterSalesRequests_Orders_OrderId",
column: x => x.OrderId,
principalTable: "Orders",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "AfterSalesAttachments",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
AfterSalesRequestId = table.Column<Guid>(type: "uuid", nullable: false),
Url = table.Column<string>(type: "text", nullable: false),
OriginalFileName = table.Column<string>(type: "text", nullable: true),
SortOrder = table.Column<int>(type: "integer", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_AfterSalesAttachments", x => x.Id);
table.ForeignKey(
name: "FK_AfterSalesAttachments_AfterSalesRequests_AfterSalesRequestId",
column: x => x.AfterSalesRequestId,
principalTable: "AfterSalesRequests",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "AfterSalesRequestLines",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
AfterSalesRequestId = table.Column<Guid>(type: "uuid", nullable: false),
OrderLineId = table.Column<Guid>(type: "uuid", nullable: false),
Quantity = table.Column<int>(type: "integer", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_AfterSalesRequestLines", x => x.Id);
table.ForeignKey(
name: "FK_AfterSalesRequestLines_AfterSalesRequests_AfterSalesRequest~",
column: x => x.AfterSalesRequestId,
principalTable: "AfterSalesRequests",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_AfterSalesRequestLines_OrderLines_OrderLineId",
column: x => x.OrderLineId,
principalTable: "OrderLines",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateIndex(
name: "IX_AfterSalesAttachments_AfterSalesRequestId",
table: "AfterSalesAttachments",
column: "AfterSalesRequestId");
migrationBuilder.CreateIndex(
name: "IX_AfterSalesRequestLines_AfterSalesRequestId",
table: "AfterSalesRequestLines",
column: "AfterSalesRequestId");
migrationBuilder.CreateIndex(
name: "IX_AfterSalesRequestLines_OrderLineId",
table: "AfterSalesRequestLines",
column: "OrderLineId");
migrationBuilder.CreateIndex(
name: "IX_AfterSalesRequests_OrderId",
table: "AfterSalesRequests",
column: "OrderId");
migrationBuilder.CreateIndex(
name: "IX_AfterSalesRequests_Status",
table: "AfterSalesRequests",
column: "Status");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "AfterSalesAttachments");
migrationBuilder.DropTable(
name: "AfterSalesRequestLines");
migrationBuilder.DropTable(
name: "AfterSalesRequests");
migrationBuilder.DropColumn(
name: "AfterSalesAccessTokenExpiresAt",
table: "Orders");
migrationBuilder.DropColumn(
name: "AfterSalesAccessTokenHash",
table: "Orders");
}
}
}
@@ -0,0 +1,97 @@
using System.Security.Cryptography;
using System.Text;
using iLED.Application.Common.Interfaces;
using iLED.Domain.Orders;
using Microsoft.Extensions.Configuration;
namespace iLED.Infrastructure.Services;
public sealed class AfterSalesAccessService(ICurrentUserService currentUser)
: IAfterSalesAccessService
{
public static readonly TimeSpan DefaultLifetime = TimeSpan.FromDays(30);
public string IssueAccessToken(Order order, TimeSpan? lifetime = null)
{
var plain = Convert.ToHexString(RandomNumberGenerator.GetBytes(32))
.ToLowerInvariant();
order.AfterSalesAccessTokenHash = Hash(plain);
order.AfterSalesAccessTokenExpiresAt =
DateTime.UtcNow.Add(lifetime ?? DefaultLifetime);
return plain;
}
public bool ValidateAccessToken(Order order, string? plainToken)
{
if (string.IsNullOrWhiteSpace(plainToken) ||
string.IsNullOrWhiteSpace(order.AfterSalesAccessTokenHash) ||
order.AfterSalesAccessTokenExpiresAt is null ||
order.AfterSalesAccessTokenExpiresAt < DateTime.UtcNow)
{
return false;
}
var hash = Hash(plainToken.Trim());
return CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(hash),
Encoding.UTF8.GetBytes(order.AfterSalesAccessTokenHash));
}
public bool CanAccessOrder(Order order, string? plainToken)
{
if (currentUser.IsAdmin)
{
return true;
}
if (currentUser.IsAuthenticated &&
!string.IsNullOrWhiteSpace(currentUser.UserId) &&
string.Equals(
order.UserId,
currentUser.UserId,
StringComparison.Ordinal))
{
return true;
}
return ValidateAccessToken(order, plainToken);
}
public static string Hash(string plainToken)
{
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(plainToken));
return Convert.ToHexString(bytes).ToLowerInvariant();
}
}
public sealed class AfterSalesEmailService(
IEmailService email,
IConfiguration configuration) : IAfterSalesEmailService
{
public async Task SendAccessLinkAsync(
Order order,
string plainToken,
CancellationToken cancellationToken = default)
{
var shopBase = configuration["Seo:ShopBaseUrl"] ?? "http://localhost:4200";
var link =
$"{shopBase.TrimEnd('/')}/after-sales" +
$"?orderId={order.Id}" +
$"&token={Uri.EscapeDataString(plainToken)}";
var subject = $"iLED — return / report issue · {order.OrderNumber}";
var body = $"""
<p>Hello {System.Net.WebUtility.HtmlEncode(order.CustomerFirstName)},</p>
<p>Use the link below to request a return or report a problem with order
<strong>{System.Net.WebUtility.HtmlEncode(order.OrderNumber)}</strong>.</p>
<p><a href="{link}">Open after-sales form</a></p>
<p>This link expires in 30 days. If you did not place this order, ignore this email.</p>
""";
await email.SendAsync(
order.CustomerEmail,
subject,
body,
cancellationToken);
}
}
@@ -0,0 +1,81 @@
using iLED.Application.Common.Interfaces;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Options;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Formats.Webp;
using SixLabors.ImageSharp.Processing;
namespace iLED.Infrastructure.Services;
public sealed class AfterSalesImageStorageOptions
{
public const string SectionName = "AfterSalesImages";
public string PublicPathPrefix { get; set; } = "/uploads/after-sales";
public int MaxWidth { get; set; } = 1600;
public int WebpQuality { get; set; } = 82;
public long MaxFileSizeBytes { get; set; } = 8 * 1024 * 1024;
}
public sealed class AfterSalesImageStorage(
IWebHostEnvironment environment,
IOptions<AfterSalesImageStorageOptions> options)
: IAfterSalesImageStorage
{
private static readonly HashSet<string> AllowedExtensions =
[
".jpg",
".jpeg",
".png",
".gif",
".webp",
".bmp",
];
private readonly AfterSalesImageStorageOptions _options = options.Value;
public async Task<string> SaveAsWebpAsync(
Stream input,
string originalFileName,
CancellationToken cancellationToken)
{
var extension = Path.GetExtension(originalFileName).ToLowerInvariant();
if (!AllowedExtensions.Contains(extension))
{
throw new Application.Common.Exceptions.ValidationException(
"Unsupported file format. Allowed: JPG, PNG, GIF, WebP, BMP.");
}
var webRoot = environment.WebRootPath
?? Path.Combine(environment.ContentRootPath, "wwwroot");
var uploadDir = Path.Combine(webRoot, "uploads", "after-sales");
Directory.CreateDirectory(uploadDir);
var fileName = $"{Guid.NewGuid():N}.webp";
var fullPath = Path.Combine(uploadDir, fileName);
using var image = await Image.LoadAsync(input, cancellationToken);
image.Mutate(x => x.AutoOrient());
if (image.Width > _options.MaxWidth)
{
image.Mutate(x => x.Resize(new ResizeOptions
{
Size = new Size(_options.MaxWidth, 0),
Mode = ResizeMode.Max,
}));
}
var encoder = new WebpEncoder
{
Quality = _options.WebpQuality,
};
await image.SaveAsync(fullPath, encoder, cancellationToken);
return $"{_options.PublicPathPrefix}/{fileName}";
}
}
@@ -320,6 +320,140 @@ public sealed class PayUPaymentService(
} }
} }
public async Task<PaymentRefundResult> RefundPaymentAsync(
PaymentRefundRequest request,
CancellationToken cancellationToken = default)
{
var settings = await integrationSettings.GetAsync(cancellationToken);
var clientId = settings.PayUClientId;
var clientSecret = settings.PayUClientSecret;
var oauthUrl = settings.PayUOauthUrl;
var ordersUrl = settings.PayUOrdersUrl;
var useSandbox = settings.PayUUseSandbox;
var hasCredentials =
!string.IsNullOrWhiteSpace(clientId) &&
!string.IsNullOrWhiteSpace(clientSecret) &&
!string.IsNullOrWhiteSpace(oauthUrl) &&
!string.IsNullOrWhiteSpace(ordersUrl);
if (!hasCredentials)
{
if (!useSandbox)
{
return new PaymentRefundResult(
false,
null,
"Configure PayU credentials in Settings → Integrations.");
}
var mockId = $"REFUND-{Guid.NewGuid():N}";
logger.LogWarning(
"PayU credentials missing — mocking refund {RefundId} for {ExternalId}",
mockId,
request.ExternalId);
return new PaymentRefundResult(true, mockId, null);
}
try
{
var token = await GetAccessTokenAsync(
oauthUrl!,
clientId!,
clientSecret!,
cancellationToken);
var amountMinor = ((int)Math.Round(
request.Amount * 100m,
MidpointRounding.AwayFromZero))
.ToString(CultureInfo.InvariantCulture);
var refundUrl =
$"{ordersUrl!.TrimEnd('/')}/{Uri.EscapeDataString(request.ExternalId)}/refunds";
var body = new
{
refund = new
{
description = string.IsNullOrWhiteSpace(request.Description)
? "Customer refund"
: request.Description,
amount = amountMinor,
},
};
var client = httpClientFactory.CreateClient(nameof(PayUPaymentService));
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, refundUrl);
httpRequest.Headers.Authorization =
new AuthenticationHeaderValue("Bearer", token);
httpRequest.Content = new StringContent(
JsonSerializer.Serialize(body),
Encoding.UTF8,
"application/json");
using var response = await client.SendAsync(
httpRequest,
cancellationToken);
var responseBody = await response.Content.ReadAsStringAsync(
cancellationToken);
if (!response.IsSuccessStatusCode)
{
logger.LogError(
"PayU refund failed: {Status} {Body}",
(int)response.StatusCode,
responseBody);
return new PaymentRefundResult(
false,
null,
"PayU refund failed.");
}
string? refundId = null;
if (!string.IsNullOrWhiteSpace(responseBody))
{
try
{
using var doc = JsonDocument.Parse(responseBody);
if (doc.RootElement.TryGetProperty("refund", out var refundEl) &&
refundEl.TryGetProperty("refundId", out var idEl))
{
refundId = idEl.GetString();
}
else if (doc.RootElement.TryGetProperty("orderId", out var oid))
{
refundId = oid.GetString();
}
}
catch (JsonException)
{
// ignore parse errors; HTTP success is enough
}
}
logger.LogInformation(
"PayU refund created for {ExternalId}: {RefundId} amount={Amount}",
request.ExternalId,
refundId,
request.Amount);
return new PaymentRefundResult(
true,
refundId ?? $"REFUND-{Guid.NewGuid():N}",
null);
}
catch (Exception ex)
{
logger.LogError(
ex,
"PayU refund error for {ExternalId}",
request.ExternalId);
return new PaymentRefundResult(
false,
null,
"PayU refund failed.");
}
}
public async Task<PaymentWebhookResult> HandleWebhookAsync( public async Task<PaymentWebhookResult> HandleWebhookAsync(
string payload, string payload,
string signature, string signature,
@@ -15,7 +15,9 @@ public sealed class OrderService(
Persistence.ApplicationDbContext db, Persistence.ApplicationDbContext db,
ICartService cartService, ICartService cartService,
ICurrentUserService currentUser, ICurrentUserService currentUser,
IPromotionRewardService promotionRewards) : IOrderService IPromotionRewardService promotionRewards,
IAfterSalesAccessService afterSalesAccess,
IAfterSalesEmailService afterSalesEmail) : IOrderService
{ {
private const decimal VatRate = 0.23m; private const decimal VatRate = 0.23m;
private const decimal CourierDeliveryCost = 15.99m; private const decimal CourierDeliveryCost = 15.99m;
@@ -142,6 +144,7 @@ public sealed class OrderService(
}).ToList(), }).ToList(),
}; };
var afterSalesToken = afterSalesAccess.IssueAccessToken(order);
db.Orders.Add(order); db.Orders.Add(order);
if (appliedCode is not null) if (appliedCode is not null)
@@ -167,6 +170,19 @@ public sealed class OrderService(
} }
await db.SaveChangesAsync(cancellationToken); await db.SaveChangesAsync(cancellationToken);
try
{
await afterSalesEmail.SendAccessLinkAsync(
order,
afterSalesToken,
cancellationToken);
}
catch
{
// Order already persisted; email failures must not roll back checkout.
}
return MapOrder(order); return MapOrder(order);
} }
+12
View File
@@ -71,6 +71,17 @@ export const routes: Routes = [
descriptionKey: 'meta.order.description', descriptionKey: 'meta.order.description',
}, },
}, },
{
path: 'after-sales',
loadComponent: () =>
import('./features/after-sales/after-sales.component').then(
(m) => m.AfterSalesComponent,
),
data: {
titleKey: 'meta.afterSales.title',
descriptionKey: 'meta.afterSales.description',
},
},
{ {
path: 'login', path: 'login',
loadComponent: () => loadComponent: () =>
@@ -211,6 +222,7 @@ export const routes: Routes = [
{ path: 'kontakt', redirectTo: 'contact', pathMatch: 'full' }, { path: 'kontakt', redirectTo: 'contact', pathMatch: 'full' },
{ path: 'dostawa', redirectTo: 'shipping', pathMatch: 'full' }, { path: 'dostawa', redirectTo: 'shipping', pathMatch: 'full' },
{ path: 'zwroty', redirectTo: 'returns', pathMatch: 'full' }, { path: 'zwroty', redirectTo: 'returns', pathMatch: 'full' },
{ path: 'reklamacje', redirectTo: 'after-sales', pathMatch: 'full' },
{ {
path: '**', path: '**',
loadComponent: () => loadComponent: () =>
+7
View File
@@ -57,6 +57,13 @@ export class ApiService {
headers: this.#getHeaders(), headers: this.#getHeaders(),
}); });
} }
/** Multipart upload; auth/locale headers only (no JSON content-type). */
uploadForm<T>(path: string, form: FormData): Observable<T> {
return this.#http.post<T>(`${API_URL}${path}`, form, {
headers: this.#getHeaders(),
});
}
} }
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
@@ -15,6 +15,9 @@ export const CS_TRANSLATIONS: TranslationMap = {
'meta.checkout.description': 'Dokončete objednávku.', 'meta.checkout.description': 'Dokončete objednávku.',
'meta.order.title': 'Potvrzení objednávky — iLED', 'meta.order.title': 'Potvrzení objednávky — iLED',
'meta.order.description': 'Děkujeme za objednávku.', 'meta.order.description': 'Děkujeme za objednávku.',
'meta.afterSales.title': 'Vrácení / reklamace — iLED',
'meta.afterSales.description':
'Požádejte o vrácení nebo nahlaste problém s objednávkou.',
'meta.login.title': 'Přihlášení — iLED', 'meta.login.title': 'Přihlášení — iLED',
'meta.login.description': 'Přihlaste se nebo vytvořte účet.', 'meta.login.description': 'Přihlaste se nebo vytvořte účet.',
'meta.favorites.title': 'Oblíbené — iLED', 'meta.favorites.title': 'Oblíbené — iLED',
@@ -360,6 +363,37 @@ export const CS_TRANSLATIONS: TranslationMap = {
'account.logout': 'Odhlásit se', 'account.logout': 'Odhlásit se',
'account.myOrders': 'Moje objednávky', 'account.myOrders': 'Moje objednávky',
'account.details': 'Detail', 'account.details': 'Detail',
'account.afterSales': 'Vrácení / reklamace',
'checkout.afterSales': 'Vrácení / reklamace',
'afterSales.title': 'Vrácení nebo nahlášení problému',
'afterSales.subtitle':
'Vyberte položky z objednávky, popište problém a volitelně přidejte fotky.',
'afterSales.selectItems': 'Vyberte položky',
'afterSales.remaining': 'Ještě lze reklamovat',
'afterSales.qty': 'Kusů',
'afterSales.requestType': 'O co jde?',
'afterSales.type.Withdrawal': 'Vrácení / odstoupení',
'afterSales.type.Defect': 'Vada / poškození',
'afterSales.type.MissingPart': 'Chybějící díl',
'afterSales.type.WrongItem': 'Špatný produkt',
'afterSales.type.Other': 'Jiné',
'afterSales.note': 'Popis',
'afterSales.notePlaceholder': 'Co je špatně…',
'afterSales.photos': 'Fotky',
'afterSales.photosHint':
'Až 5 fotek. Na telefonu můžete použít fotoaparát.',
'afterSales.uploading': 'Nahrávání…',
'afterSales.maxPhotos': 'Maximálně 5 fotek.',
'afterSales.uploadError': 'Fotku se nepodařilo nahrát.',
'afterSales.submit': 'Odeslat žádost',
'afterSales.submitting': 'Odesílání…',
'afterSales.submitError': 'Žádost se nepodařilo odeslat.',
'afterSales.successTitle': 'Žádost přijata',
'afterSales.successBody':
'Prohlédneme ji a ozveme se e-mailem.',
'afterSales.missingOrder':
'Otevřete stránku z e-mailu objednávky nebo účtu.',
'afterSales.loadError': 'Objednávku se nepodařilo načíst.',
'account.noOrders': 'Žádné objednávky.', 'account.noOrders': 'Žádné objednávky.',
'account.loginPrompt': ' pro zobrazení účtu.', 'account.loginPrompt': ' pro zobrazení účtu.',
'favorites.title': 'Oblíbené produkty', 'favorites.title': 'Oblíbené produkty',
@@ -15,6 +15,9 @@ export const DE_TRANSLATIONS: TranslationMap = {
'meta.checkout.description': 'Bestellung abschließen.', 'meta.checkout.description': 'Bestellung abschließen.',
'meta.order.title': 'Bestellbestätigung — iLED', 'meta.order.title': 'Bestellbestätigung — iLED',
'meta.order.description': 'Vielen Dank für Ihre Bestellung.', 'meta.order.description': 'Vielen Dank für Ihre Bestellung.',
'meta.afterSales.title': 'Rückgabe / Reklamation — iLED',
'meta.afterSales.description':
'Rückgabe oder Problem zu Ihrer Bestellung melden.',
'meta.login.title': 'Anmelden — iLED', 'meta.login.title': 'Anmelden — iLED',
'meta.login.description': 'Anmelden oder Konto erstellen.', 'meta.login.description': 'Anmelden oder Konto erstellen.',
'meta.favorites.title': 'Favoriten — iLED', 'meta.favorites.title': 'Favoriten — iLED',
@@ -361,6 +364,37 @@ export const DE_TRANSLATIONS: TranslationMap = {
'account.logout': 'Abmelden', 'account.logout': 'Abmelden',
'account.myOrders': 'Meine Bestellungen', 'account.myOrders': 'Meine Bestellungen',
'account.details': 'Details', 'account.details': 'Details',
'account.afterSales': 'Rückgabe / melden',
'checkout.afterSales': 'Rückgabe / Problem melden',
'afterSales.title': 'Rückgabe oder Problem melden',
'afterSales.subtitle':
'Wählen Sie Artikel, beschreiben Sie das Problem und fügen Sie optional Fotos hinzu.',
'afterSales.selectItems': 'Artikel wählen',
'afterSales.remaining': 'Noch meldebar',
'afterSales.qty': 'Menge',
'afterSales.requestType': 'Worum geht es?',
'afterSales.type.Withdrawal': 'Rückgabe / Widerruf',
'afterSales.type.Defect': 'Defekt / Beschädigung',
'afterSales.type.MissingPart': 'Fehlendes Teil',
'afterSales.type.WrongItem': 'Falscher Artikel',
'afterSales.type.Other': 'Sonstiges',
'afterSales.note': 'Beschreibung',
'afterSales.notePlaceholder': 'Was ist falsch…',
'afterSales.photos': 'Fotos',
'afterSales.photosHint':
'Bis zu 5 Fotos. Am Handy können Sie die Kamera nutzen.',
'afterSales.uploading': 'Hochladen…',
'afterSales.maxPhotos': 'Maximal 5 Fotos.',
'afterSales.uploadError': 'Foto konnte nicht hochgeladen werden.',
'afterSales.submit': 'Anfrage senden',
'afterSales.submitting': 'Senden…',
'afterSales.submitError': 'Anfrage konnte nicht gesendet werden.',
'afterSales.successTitle': 'Anfrage erhalten',
'afterSales.successBody':
'Wir prüfen sie und melden uns per E-Mail.',
'afterSales.missingOrder':
'Öffnen Sie diese Seite aus der Bestell-E-Mail oder dem Konto.',
'afterSales.loadError': 'Bestellung konnte nicht geladen werden.',
'account.noOrders': 'Keine Bestellungen.', 'account.noOrders': 'Keine Bestellungen.',
'account.loginPrompt': ', um Ihr Konto zu sehen.', 'account.loginPrompt': ', um Ihr Konto zu sehen.',
'favorites.title': 'Lieblingsprodukte', 'favorites.title': 'Lieblingsprodukte',
@@ -15,6 +15,9 @@ export const EN_TRANSLATIONS: TranslationMap = {
'meta.checkout.description': 'Complete your order.', 'meta.checkout.description': 'Complete your order.',
'meta.order.title': 'Order confirmation — iLED', 'meta.order.title': 'Order confirmation — iLED',
'meta.order.description': 'Thank you for your order.', 'meta.order.description': 'Thank you for your order.',
'meta.afterSales.title': 'Return / report issue — iLED',
'meta.afterSales.description':
'Request a return or report a problem with your order.',
'meta.login.title': 'Sign in — iLED', 'meta.login.title': 'Sign in — iLED',
'meta.login.description': 'Sign in or create an account.', 'meta.login.description': 'Sign in or create an account.',
'meta.favorites.title': 'Favorites — iLED', 'meta.favorites.title': 'Favorites — iLED',
@@ -356,7 +359,37 @@ export const EN_TRANSLATIONS: TranslationMap = {
'account.logout': 'Sign out', 'account.logout': 'Sign out',
'account.myOrders': 'My orders', 'account.myOrders': 'My orders',
'account.details': 'Details', 'account.details': 'Details',
'account.afterSales': 'Return / report',
'account.noOrders': 'No orders yet.', 'account.noOrders': 'No orders yet.',
'checkout.afterSales': 'Return / report issue',
'afterSales.title': 'Return or report an issue',
'afterSales.subtitle':
'Select items from your order, describe the problem, and optionally add photos.',
'afterSales.selectItems': 'Select items',
'afterSales.remaining': 'Still claimable',
'afterSales.qty': 'Qty',
'afterSales.requestType': 'What is this about?',
'afterSales.type.Withdrawal': 'Return / withdrawal',
'afterSales.type.Defect': 'Defect / damage',
'afterSales.type.MissingPart': 'Missing part',
'afterSales.type.WrongItem': 'Wrong item received',
'afterSales.type.Other': 'Other',
'afterSales.note': 'Description',
'afterSales.notePlaceholder': 'Tell us what is wrong…',
'afterSales.photos': 'Photos',
'afterSales.photosHint':
'Up to 5 photos. On a phone you can take a picture with the camera.',
'afterSales.uploading': 'Uploading…',
'afterSales.maxPhotos': 'Maximum 5 photos.',
'afterSales.uploadError': 'Could not upload photo.',
'afterSales.submit': 'Submit request',
'afterSales.submitting': 'Sending…',
'afterSales.submitError': 'Could not submit request.',
'afterSales.successTitle': 'Request received',
'afterSales.successBody':
'We will review it and get back to you by email.',
'afterSales.missingOrder': 'Open this page from your order email or account.',
'afterSales.loadError': 'Could not load this order for after-sales.',
'account.loginPrompt': ' to view your account.', 'account.loginPrompt': ' to view your account.',
'favorites.title': 'Favorite products', 'favorites.title': 'Favorite products',
'favorites.empty': 'No favorite products yet.', 'favorites.empty': 'No favorite products yet.',
@@ -15,6 +15,9 @@ export const PL_TRANSLATIONS: TranslationMap = {
'meta.checkout.description': 'Dokończ zamówienie.', 'meta.checkout.description': 'Dokończ zamówienie.',
'meta.order.title': 'Potwierdzenie zamówienia — iLED', 'meta.order.title': 'Potwierdzenie zamówienia — iLED',
'meta.order.description': 'Dziękujemy za zamówienie.', 'meta.order.description': 'Dziękujemy za zamówienie.',
'meta.afterSales.title': 'Zwrot / reklamacja — iLED',
'meta.afterSales.description':
'Zgłoś zwrot lub problem z zamówieniem.',
'meta.login.title': 'Logowanie — iLED', 'meta.login.title': 'Logowanie — iLED',
'meta.login.description': 'Zaloguj się lub załóż konto.', 'meta.login.description': 'Zaloguj się lub załóż konto.',
'meta.favorites.title': 'Ulubione — iLED', 'meta.favorites.title': 'Ulubione — iLED',
@@ -356,6 +359,37 @@ export const PL_TRANSLATIONS: TranslationMap = {
'account.logout': 'Wyloguj', 'account.logout': 'Wyloguj',
'account.myOrders': 'Moje zamówienia', 'account.myOrders': 'Moje zamówienia',
'account.details': 'Szczegóły', 'account.details': 'Szczegóły',
'account.afterSales': 'Zwrot / reklamacja',
'checkout.afterSales': 'Zwrot / reklamacja',
'afterSales.title': 'Zwrot lub zgłoszenie problemu',
'afterSales.subtitle':
'Wybierz produkty z zamówienia, opisz problem i opcjonalnie dodaj zdjęcia.',
'afterSales.selectItems': 'Wybierz produkty',
'afterSales.remaining': 'Pozostało do zgłoszenia',
'afterSales.qty': 'Ilość',
'afterSales.requestType': 'Czego dotyczy zgłoszenie?',
'afterSales.type.Withdrawal': 'Zwrot / odstąpienie',
'afterSales.type.Defect': 'Wada / uszkodzenie',
'afterSales.type.MissingPart': 'Brakująca część',
'afterSales.type.WrongItem': 'Otrzymano zły produkt',
'afterSales.type.Other': 'Inne',
'afterSales.note': 'Opis',
'afterSales.notePlaceholder': 'Opisz, co jest nie tak…',
'afterSales.photos': 'Zdjęcia',
'afterSales.photosHint':
'Do 5 zdjęć. Na telefonie możesz zrobić zdjęcie aparatem.',
'afterSales.uploading': 'Przesyłanie…',
'afterSales.maxPhotos': 'Maksymalnie 5 zdjęć.',
'afterSales.uploadError': 'Nie udało się przesłać zdjęcia.',
'afterSales.submit': 'Wyślij zgłoszenie',
'afterSales.submitting': 'Wysyłanie…',
'afterSales.submitError': 'Nie udało się wysłać zgłoszenia.',
'afterSales.successTitle': 'Zgłoszenie przyjęte',
'afterSales.successBody':
'Przejrzymy je i odezwiemy się e-mailem.',
'afterSales.missingOrder':
'Otwórz tę stronę z e-maila zamówienia lub konta.',
'afterSales.loadError': 'Nie udało się wczytać zamówienia.',
'account.noOrders': 'Brak zamówień.', 'account.noOrders': 'Brak zamówień.',
'account.loginPrompt': ', aby zobaczyć konto.', 'account.loginPrompt': ', aby zobaczyć konto.',
'favorites.title': 'Ulubione produkty', 'favorites.title': 'Ulubione produkty',
+66
View File
@@ -176,6 +176,72 @@ export class OrderService {
} }
} }
export type AfterSalesRequestType =
| 'withdrawal'
| 'defect'
| 'missingPart'
| 'wrongItem'
| 'other';
export interface AfterSalesOrderLine {
orderLineId: string;
productName: string;
variantColor: string;
sku: string;
imageUrl: string | null;
unitPrice: number;
quantity: number;
remainingClaimable: number;
}
export interface AfterSalesOrder {
orderId: string;
orderNumber: string;
status: string;
createdAt: string;
lines: AfterSalesOrderLine[];
}
@Injectable({ providedIn: 'root' })
export class AfterSalesService {
readonly #api = inject(ApiService);
getOrder(orderId: string, token?: string | null) {
const params: Record<string, string> = {};
if (token) {
params['token'] = token;
}
return this.#api.get<AfterSalesOrder>(
`/after-sales/orders/${orderId}`,
params,
);
}
upload(orderId: string, file: File, token?: string | null) {
const form = new FormData();
form.append('orderId', orderId);
if (token) {
form.append('token', token);
}
form.append('file', file);
return this.#api.uploadForm<{ url: string }>(
'/after-sales/uploads',
form,
);
}
create(body: {
orderId: string;
token?: string | null;
type: AfterSalesRequestType;
customerNote: string;
lines: { orderLineId: string; quantity: number }[];
attachmentUrls: string[];
}) {
return this.#api.post<{ id: string }>('/after-sales/requests', body);
}
}
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class FavoritesService { export class FavoritesService {
readonly #api = inject(ApiService); readonly #api = inject(ApiService);
@@ -56,11 +56,18 @@
<span class="font-bold">{{ order.total | i18nCurrency }}</span> <span class="font-bold">{{ order.total | i18nCurrency }}</span>
<div class="flex flex-wrap gap-3">
<a routerLink="/order/{{ order.id }}" class="text-iled-primary hover:underline"> <a routerLink="/order/{{ order.id }}" class="text-iled-primary hover:underline">
{{ 'account.details' | translate }} {{ 'account.details' | translate }}
</a> </a>
<a
[routerLink]="['/after-sales']"
[queryParams]="{ orderId: order.id }"
class="text-iled-primary hover:underline"
>
{{ 'account.afterSales' | translate }}
</a>
</div>
</div> </div>
@@ -0,0 +1,170 @@
<app-shell>
<div class="mx-auto max-w-2xl px-6 py-12">
<h1 class="mb-2 text-3xl font-bold">
{{ 'afterSales.title' | translate }}
</h1>
<p class="mb-8 text-iled-grey-dark">
{{ 'afterSales.subtitle' | translate }}
</p>
@if (loading()) {
<p class="text-iled-grey-dark">{{ 'common.loading' | translate }}</p>
} @else if (success()) {
<div class="rounded border border-iled-grey-light bg-white p-6 text-center">
<h2 class="mb-2 text-xl font-bold">
{{ 'afterSales.successTitle' | translate }}
</h2>
<p class="mb-6 text-iled-grey-dark">
{{ 'afterSales.successBody' | translate }}
</p>
<a routerLink="/" class="inline-flex bg-iled-black px-6 py-3 text-white">
{{ 'checkout.backToShop' | translate }}
</a>
</div>
} @else if (order(); as o) {
<form class="space-y-8" (ngSubmit)="handleSubmit($event)">
<div>
<p class="mb-1 text-sm text-iled-grey-dark">
{{ 'checkout.orderNumber' | translate }}
<strong>{{ o.orderNumber }}</strong>
</p>
</div>
<fieldset>
<legend class="mb-3 font-semibold">
{{ 'afterSales.selectItems' | translate }}
</legend>
<div class="space-y-3">
@for (line of o.lines; track line.orderLineId) {
<div
class="flex flex-wrap items-center justify-between gap-3 border border-iled-grey-light p-3"
>
<div class="min-w-0 flex-1 text-left">
<div class="font-medium">{{ line.productName }}</div>
<div class="text-sm text-iled-grey-dark">
{{ line.sku }}
@if (line.variantColor) {
· {{ line.variantColor }}
}
· {{ line.unitPrice | i18nCurrency }}
</div>
<div class="text-xs text-iled-grey-dark">
{{ 'afterSales.remaining' | translate }}:
{{ line.remainingClaimable }} / {{ line.quantity }}
</div>
</div>
<label class="flex items-center gap-2 text-sm">
<span>{{ 'afterSales.qty' | translate }}</span>
<input
type="number"
class="w-20 border border-iled-grey-light px-2 py-1"
min="0"
[max]="line.remainingClaimable"
[value]="qty(line)"
[disabled]="line.remainingClaimable === 0"
(change)="setQty(line, $any($event.target).value)"
/>
</label>
</div>
}
</div>
</fieldset>
<fieldset>
<legend class="mb-3 font-semibold">
{{ 'afterSales.requestType' | translate }}
</legend>
<div class="space-y-2 text-left">
@for (t of types; track t) {
<label class="flex items-center gap-2">
<input
type="radio"
name="afterSalesType"
[value]="t"
[(ngModel)]="type"
/>
{{ typeLabel(t) | translate }}
</label>
}
</div>
</fieldset>
<div class="text-left">
<label class="mb-2 block font-semibold" for="as-note">
{{ 'afterSales.note' | translate }}
</label>
<textarea
id="as-note"
name="note"
rows="4"
class="w-full border border-iled-grey-light px-3 py-2"
[(ngModel)]="note"
[placeholder]="'afterSales.notePlaceholder' | translate"
></textarea>
</div>
<div class="text-left">
<label class="mb-2 block font-semibold">
{{ 'afterSales.photos' | translate }}
</label>
<p class="mb-3 text-sm text-iled-grey-dark">
{{ 'afterSales.photosHint' | translate }}
</p>
<input
type="file"
accept="image/*"
capture="environment"
multiple
class="mb-3 block w-full text-sm"
[disabled]="uploading() || attachmentUrls().length >= 5"
(change)="handleFiles($event)"
/>
@if (uploading()) {
<p class="mb-2 text-sm text-iled-grey-dark">
{{ 'afterSales.uploading' | translate }}
</p>
}
<div class="flex flex-wrap gap-2">
@for (url of attachmentUrls(); track url) {
<div class="relative">
<img
[src]="photoSrc(url)"
alt=""
class="h-20 w-20 object-cover"
/>
<button
type="button"
class="absolute right-0 top-0 bg-black/70 px-1 text-xs text-white"
(click)="removePhoto(url)"
>
✕
</button>
</div>
}
</div>
</div>
@if (error()) {
<p class="text-sm text-red-700">{{ error() }}</p>
}
<button
type="submit"
class="w-full bg-iled-primary px-6 py-3 font-semibold text-iled-black disabled:opacity-50"
[disabled]="submitting() || uploading()"
>
@if (submitting()) {
{{ 'afterSales.submitting' | translate }}
} @else {
{{ 'afterSales.submit' | translate }}
}
</button>
</form>
} @else if (error()) {
<p class="text-red-700">{{ error() }}</p>
<a routerLink="/" class="mt-4 inline-block text-iled-primary underline">
{{ 'checkout.backToShop' | translate }}
</a>
}
</div>
</app-shell>
@@ -0,0 +1,204 @@
import {
ChangeDetectionStrategy,
Component,
inject,
OnInit,
signal,
} from '@angular/core';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { I18nCurrencyPipe } from '../../core/i18n/i18n-currency.pipe';
import { TranslatePipe } from '../../core/i18n/translate.pipe';
import { I18nService } from '../../core/i18n/i18n.service';
import { ShellComponent } from '../../shared/layout/layout.components';
import { API_BASE_URL } from '../../core/constants';
import {
AfterSalesOrder,
AfterSalesOrderLine,
AfterSalesRequestType,
AfterSalesService,
} from '../../core/shop.services';
@Component({
selector: 'app-after-sales',
standalone: true,
imports: [
ShellComponent,
FormsModule,
TranslatePipe,
I18nCurrencyPipe,
RouterLink,
],
templateUrl: './after-sales.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AfterSalesComponent implements OnInit {
readonly #api = inject(AfterSalesService);
readonly #route = inject(ActivatedRoute);
readonly #i18n = inject(I18nService);
readonly loading = signal(true);
readonly submitting = signal(false);
readonly uploading = signal(false);
readonly error = signal('');
readonly success = signal(false);
readonly order = signal<AfterSalesOrder | null>(null);
orderId = '';
token = '';
type: AfterSalesRequestType = 'withdrawal';
note = '';
readonly selectedQty = new Map<string, number>();
readonly attachmentUrls = signal<string[]>([]);
readonly types: AfterSalesRequestType[] = [
'withdrawal',
'defect',
'missingPart',
'wrongItem',
'other',
];
ngOnInit(): void {
this.#route.queryParamMap.subscribe((params) => {
this.orderId = params.get('orderId') ?? '';
this.token = params.get('token') ?? '';
if (this.orderId) {
this.#load();
} else {
this.loading.set(false);
this.error.set(this.#i18n.t('afterSales.missingOrder'));
}
});
}
#load(): void {
this.loading.set(true);
this.error.set('');
this.#api.getOrder(this.orderId, this.token || null).subscribe({
next: (o) => {
this.order.set(o);
this.selectedQty.clear();
for (const line of o.lines) {
if (line.remainingClaimable > 0) {
this.selectedQty.set(line.orderLineId, 0);
}
}
this.loading.set(false);
},
error: () => {
this.loading.set(false);
this.error.set(this.#i18n.t('afterSales.loadError'));
},
});
}
qty(line: AfterSalesOrderLine): number {
return this.selectedQty.get(line.orderLineId) ?? 0;
}
setQty(line: AfterSalesOrderLine, raw: string | number): void {
const n = Math.max(
0,
Math.min(line.remainingClaimable, Math.floor(Number(raw) || 0)),
);
this.selectedQty.set(line.orderLineId, n);
}
typeLabel(type: AfterSalesRequestType): string {
const key = type.charAt(0).toUpperCase() + type.slice(1);
return `afterSales.type.${key}`;
}
photoSrc(url: string): string {
if (url.startsWith('http://') || url.startsWith('https://')) {
return url;
}
return `${API_BASE_URL}${url}`;
}
handleFiles(event: Event): void {
const input = event.target as HTMLInputElement;
const files = input.files;
input.value = '';
if (!files?.length || this.uploading()) {
return;
}
const remaining = 5 - this.attachmentUrls().length;
if (remaining <= 0) {
this.error.set(this.#i18n.t('afterSales.maxPhotos'));
return;
}
const batch = Array.from(files).slice(0, remaining);
this.uploading.set(true);
this.error.set('');
let pending = batch.length;
for (const file of batch) {
this.#api.upload(this.orderId, file, this.token || null).subscribe({
next: (res) => {
this.attachmentUrls.update((urls) => [...urls, res.url]);
pending -= 1;
if (pending <= 0) {
this.uploading.set(false);
}
},
error: () => {
pending -= 1;
this.error.set(this.#i18n.t('afterSales.uploadError'));
if (pending <= 0) {
this.uploading.set(false);
}
},
});
}
}
removePhoto(url: string): void {
this.attachmentUrls.update((urls) => urls.filter((u) => u !== url));
}
handleSubmit(event: Event): void {
event.preventDefault();
if (this.submitting() || !this.order()) {
return;
}
const lines = [...this.selectedQty.entries()]
.filter(([, q]) => q > 0)
.map(([orderLineId, quantity]) => ({ orderLineId, quantity }));
if (lines.length === 0) {
this.error.set(this.#i18n.t('afterSales.selectItems'));
return;
}
this.submitting.set(true);
this.error.set('');
this.#api
.create({
orderId: this.orderId,
token: this.token || null,
type: this.type,
customerNote: this.note.trim(),
lines,
attachmentUrls: this.attachmentUrls(),
})
.subscribe({
next: () => {
this.submitting.set(false);
this.success.set(true);
},
error: (err) => {
this.submitting.set(false);
const msg =
err?.error?.message ||
err?.error?.title ||
this.#i18n.t('afterSales.submitError');
this.error.set(typeof msg === 'string' ? msg : this.#i18n.t('afterSales.submitError'));
},
});
}
}
@@ -353,6 +353,7 @@ export class OrderConfirmationComponent implements OnInit {
readonly #route = inject(ActivatedRoute); readonly #route = inject(ActivatedRoute);
readonly #router = inject(Router); readonly #router = inject(Router);
readonly #i18n = inject(I18nService); readonly #i18n = inject(I18nService);
readonly #auth = inject(AuthService);
readonly order = signal<OrderDto | null>(null); readonly order = signal<OrderDto | null>(null);
readonly syncing = signal(false); readonly syncing = signal(false);
@@ -369,9 +370,23 @@ export class OrderConfirmationComponent implements OnInit {
} }
needsPayment(order: OrderDto): boolean { needsPayment(order: OrderDto): boolean {
const method = enumKey(order.paymentMethod);
const status = enumKey(order.status);
return method === 'PayU' && status === 'PendingPayment';
}
canAfterSales(order: OrderDto): boolean {
if (!this.#auth.isLoggedIn()) {
// Guests use the emailed magic link (token required).
return false;
}
const status = enumKey(order.status);
return ( return (
order.paymentMethod === 'PayU' && status === 'Paid' ||
order.status === 'PendingPayment' status === 'Processing' ||
status === 'Shipped' ||
status === 'Delivered'
); );
} }
@@ -417,7 +432,10 @@ export class OrderConfirmationComponent implements OnInit {
const order = await firstValueFrom(this.#orders.getOrder(orderId)); const order = await firstValueFrom(this.#orders.getOrder(orderId));
this.order.set(order); this.order.set(order);
if (order.status === 'Paid' || order.paymentMethod !== 'PayU') { if (
enumKey(order.status) === 'Paid' ||
enumKey(order.paymentMethod) !== 'PayU'
) {
break; break;
} }
@@ -64,6 +64,14 @@
{{ 'checkout.payAgain' | translate }} {{ 'checkout.payAgain' | translate }}
} }
</button> </button>
} @else if (canAfterSales(o)) {
<a
[routerLink]="['/after-sales']"
[queryParams]="{ orderId: o.id }"
class="inline-flex bg-iled-primary px-8 py-3 font-semibold text-iled-black"
>
{{ 'checkout.afterSales' | translate }}
</a>
} }
<a <a
routerLink="/" routerLink="/"