diff --git a/src/admin/src/app/app.routes.ts b/src/admin/src/app/app.routes.ts
index 5b7bdb7..8dafa9d 100644
--- a/src/admin/src/app/app.routes.ts
+++ b/src/admin/src/app/app.routes.ts
@@ -46,6 +46,13 @@ export const routes: Routes = [
(m) => m.OrdersAdminComponent,
),
},
+ {
+ path: 'after-sales',
+ loadComponent: () =>
+ import('./features/after-sales/after-sales-admin.component').then(
+ (m) => m.AfterSalesAdminComponent,
+ ),
+ },
{
path: 'inventory',
loadComponent: () =>
diff --git a/src/admin/src/app/core/i18n/translations/cs.ts b/src/admin/src/app/core/i18n/translations/cs.ts
index 138b802..484f636 100644
--- a/src/admin/src/app/core/i18n/translations/cs.ts
+++ b/src/admin/src/app/core/i18n/translations/cs.ts
@@ -8,6 +8,42 @@ export const CS_TRANSLATIONS: TranslationMap = {
'nav.analytics': 'Analytika',
'nav.products': 'Produkty',
'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.discounts': 'Akce',
'nav.newsletter': 'Newsletter',
diff --git a/src/admin/src/app/core/i18n/translations/de.ts b/src/admin/src/app/core/i18n/translations/de.ts
index 1be0193..541e0fc 100644
--- a/src/admin/src/app/core/i18n/translations/de.ts
+++ b/src/admin/src/app/core/i18n/translations/de.ts
@@ -8,6 +8,42 @@ export const DE_TRANSLATIONS: TranslationMap = {
'nav.analytics': 'Analysen',
'nav.products': 'Produkte',
'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.discounts': 'Aktionen',
'nav.newsletter': 'Newsletter',
diff --git a/src/admin/src/app/core/i18n/translations/en.ts b/src/admin/src/app/core/i18n/translations/en.ts
index 2bc1670..2ecb7a4 100644
--- a/src/admin/src/app/core/i18n/translations/en.ts
+++ b/src/admin/src/app/core/i18n/translations/en.ts
@@ -8,6 +8,42 @@ export const EN_TRANSLATIONS: TranslationMap = {
'nav.analytics': 'Analytics',
'nav.products': 'Products',
'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.discounts': 'Promotions',
'nav.newsletter': 'Newsletter',
diff --git a/src/admin/src/app/core/i18n/translations/pl.ts b/src/admin/src/app/core/i18n/translations/pl.ts
index 019d1e0..a244fe7 100644
--- a/src/admin/src/app/core/i18n/translations/pl.ts
+++ b/src/admin/src/app/core/i18n/translations/pl.ts
@@ -8,6 +8,42 @@ export const PL_TRANSLATIONS: TranslationMap = {
'nav.analytics': 'Analityka',
'nav.products': 'Produkty',
'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.discounts': 'Promocje',
'nav.newsletter': 'Newsletter',
diff --git a/src/admin/src/app/features/after-sales/after-sales-admin.component.html b/src/admin/src/app/features/after-sales/after-sales-admin.component.html
new file mode 100644
index 0000000..04e5253
--- /dev/null
+++ b/src/admin/src/app/features/after-sales/after-sales-admin.component.html
@@ -0,0 +1,285 @@
+
+
+
+
+
+
+
+
+
+
+
+@if (loading()) {
+
+} @else {
+
+
+
+
+
+ | {{ 'afterSales.colCreated' | translate }} |
+ {{ 'afterSales.colOrder' | translate }} |
+ {{ 'afterSales.colCustomer' | translate }} |
+ {{ 'afterSales.colType' | translate }} |
+ {{ 'orders.status' | translate }} |
+ |
+
+
+
+ @for (item of items(); track item.id) {
+
+ | {{ item.createdAt | date: 'yyyy-MM-dd HH:mm' }} |
+ {{ item.orderNumber }} |
+
+ {{ item.customerName }}
+ {{ item.customerEmail }}
+ |
+ {{ 'afterSales.type.' + enumKey(item.type) | translate }} |
+
+
+ {{ 'afterSales.status.' + enumKey(item.status) | translate }}
+
+ |
+
+
+ |
+
+ } @empty {
+
+ |
+ {{ 'afterSales.empty' | translate }}
+ |
+
+ }
+
+
+
+
+
+}
+
+@if (selected(); as d) {
+
+
+
+
+
{{ d.orderNumber }}
+
+ {{ 'afterSales.type.' + enumKey(d.type) | translate }}
+ ·
+
+ {{ 'afterSales.status.' + enumKey(d.status) | translate }}
+
+
+
+
+
+
+
+
+
+
{{ 'afterSales.colCustomer' | translate }}
+
{{ d.customerName }}
+
{{ d.customerEmail }}
+
+
+
{{ 'afterSales.colCreated' | translate }}
+
{{ d.createdAt | date: 'yyyy-MM-dd HH:mm' }}
+
+
+
+
+
+
{{ 'afterSales.lines' | translate }}
+
+
+
+
+
+ | {{ 'products.name' | translate }} |
+ SKU |
+ {{ 'orders.qty' | translate }} |
+ {{ 'products.price' | translate }} |
+
+
+
+ @for (line of d.lines; track line.orderLineId) {
+
+ | {{ line.productName }} |
+ {{ line.sku }} |
+ {{ line.quantity }} |
+ {{ line.unitPrice | i18nCurrency }} |
+
+ }
+
+
+
+
+
+
+
{{ 'afterSales.customerNote' | translate }}
+
+ {{ d.customerNote || '—' }}
+
+
+
+ @if (d.attachments.length) {
+
+
{{ 'afterSales.photos' | translate }}
+
+ @for (att of d.attachments; track att.id) {
+
+
+
+ }
+
+
+ }
+
+ @if (d.resolution) {
+
+
{{ 'afterSales.resolution' | translate }}
+
+ {{ 'afterSales.resolution.' + enumKey(d.resolution) | translate }}
+ @if (d.refundAmount != null) {
+ · {{ d.refundAmount | i18nCurrency }}
+ }
+ @if (d.payURefundId) {
+ · {{ d.payURefundId }}
+ }
+
+ @if (d.adminNote) {
+
+ {{ d.adminNote }}
+
+ }
+
+ }
+
+ @if (isOpen(d)) {
+
+
+
{{ 'afterSales.resolveTitle' | translate }}
+
+
+
+
+
+
+ @if (resolution === 'refund') {
+
+
+
+
+ }
+
+
+
+
+
+
+
+ }
+
+
+
+}
diff --git a/src/admin/src/app/features/after-sales/after-sales-admin.component.ts b/src/admin/src/app/features/after-sales/after-sales-admin.component.ts
new file mode 100644
index 0000000..a4adc2c
--- /dev/null
+++ b/src/admin/src/app/features/after-sales/after-sales-admin.component.ts
@@ -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([]);
+ readonly totalCount = signal(0);
+ readonly page = signal(1);
+ readonly pageSize = 20;
+ readonly selected = signal(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(`/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(
+ `/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',
+ );
+ },
+ });
+ }
+}
diff --git a/src/admin/src/app/features/after-sales/after-sales.models.ts b/src/admin/src/app/features/after-sales/after-sales.models.ts
new file mode 100644
index 0000000..a1c3f0f
--- /dev/null
+++ b/src/admin/src/app/features/after-sales/after-sales.models.ts
@@ -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;
+}
diff --git a/src/admin/src/app/layout/admin-layout.component.html b/src/admin/src/app/layout/admin-layout.component.html
index 08631e7..bd20be8 100644
--- a/src/admin/src/app/layout/admin-layout.component.html
+++ b/src/admin/src/app/layout/admin-layout.component.html
@@ -56,6 +56,15 @@
{{ 'nav.orders' | translate }}
+
+
+ {{ 'nav.afterSales' | translate }}
+
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 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 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 GetMine(CancellationToken cancellationToken)
+ {
+ var result = await mediator.Send(
+ new GetMyAfterSalesRequestsQuery(),
+ cancellationToken);
+ return Ok(result);
+ }
+
+ [HttpGet("admin")]
+ [Authorize(Roles = "Admin")]
+ public async Task 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 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 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? Lines { get; set; }
+
+ public List? AttachmentUrls { get; set; }
+}
+
+public sealed class ResolveAfterSalesBody
+{
+ public AfterSalesResolution Resolution { get; set; }
+
+ public string? AdminNote { get; set; }
+
+ public decimal? RefundAmount { get; set; }
+}
diff --git a/src/backend/src/iLED.Application/AfterSales/AfterSalesCommands.cs b/src/backend/src/iLED.Application/AfterSales/AfterSalesCommands.cs
new file mode 100644
index 0000000..f5f762c
--- /dev/null
+++ b/src/backend/src/iLED.Application/AfterSales/AfterSalesCommands.cs
@@ -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 Lines,
+ IReadOnlyList AttachmentUrls) : IRequest;
+
+public sealed record UploadAfterSalesImageCommand(
+ Guid OrderId,
+ string? AccessToken,
+ Stream ImageStream,
+ string FileName) : IRequest;
+
+public sealed record AfterSalesUploadResult(string Url);
+
+public sealed record GetAfterSalesOrderQuery(
+ Guid OrderId,
+ string? AccessToken) : IRequest;
+
+public sealed record GetMyAfterSalesRequestsQuery
+ : IRequest>;
+
+public sealed record GetAdminAfterSalesQuery(
+ AfterSalesRequestStatus? Status = null,
+ int Page = 1,
+ int PageSize = 30) : IRequest;
+
+public sealed record GetAdminAfterSalesDetailQuery(Guid Id)
+ : IRequest;
+
+public sealed record ResolveAfterSalesRequestCommand(
+ Guid Id,
+ AfterSalesResolution Resolution,
+ string? AdminNote,
+ decimal? RefundAmount) : IRequest;
+
+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 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 Lines,
+ IReadOnlyList Attachments);
+
+public sealed record AdminAfterSalesListDto(
+ IReadOnlyList Items,
+ int TotalCount,
+ int Page,
+ int PageSize);
+
+public sealed class GetAfterSalesOrderQueryHandler(
+ IAppDbContext db,
+ IAfterSalesAccessService access)
+ : IRequestHandler
+{
+ public async Task 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
+{
+ public async Task 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
+{
+ public async Task 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>
+{
+ public async Task> 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
+{
+ public async Task 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
+{
+ public async Task 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
+{
+ public async Task 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);
+ }
+}
diff --git a/src/backend/src/iLED.Application/Common/Interfaces/IAfterSalesAccessService.cs b/src/backend/src/iLED.Application/Common/Interfaces/IAfterSalesAccessService.cs
new file mode 100644
index 0000000..bf65937
--- /dev/null
+++ b/src/backend/src/iLED.Application/Common/Interfaces/IAfterSalesAccessService.cs
@@ -0,0 +1,21 @@
+using iLED.Domain.Orders;
+
+namespace iLED.Application.Common.Interfaces;
+
+public interface IAfterSalesAccessService
+{
+ /// Creates a plain token, stores hash+expiry on the order.
+ 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);
+}
diff --git a/src/backend/src/iLED.Application/Common/Interfaces/IAfterSalesImageStorage.cs b/src/backend/src/iLED.Application/Common/Interfaces/IAfterSalesImageStorage.cs
new file mode 100644
index 0000000..96d7412
--- /dev/null
+++ b/src/backend/src/iLED.Application/Common/Interfaces/IAfterSalesImageStorage.cs
@@ -0,0 +1,9 @@
+namespace iLED.Application.Common.Interfaces;
+
+public interface IAfterSalesImageStorage
+{
+ Task SaveAsWebpAsync(
+ Stream input,
+ string originalFileName,
+ CancellationToken cancellationToken);
+}
diff --git a/src/backend/src/iLED.Application/Common/Interfaces/IAppDbContext.cs b/src/backend/src/iLED.Application/Common/Interfaces/IAppDbContext.cs
index e461ffe..1d7bc47 100644
--- a/src/backend/src/iLED.Application/Common/Interfaces/IAppDbContext.cs
+++ b/src/backend/src/iLED.Application/Common/Interfaces/IAppDbContext.cs
@@ -28,6 +28,12 @@ public interface IAppDbContext : IApplicationDbContext
IQueryable Orders { get; }
+ IQueryable OrderLines { get; }
+
+ IQueryable AfterSalesRequests { get; }
+
+ IQueryable AfterSalesAttachments { get; }
+
IQueryable Payments { get; }
IQueryable Shipments { get; }
diff --git a/src/backend/src/iLED.Application/Common/Interfaces/IPaymentService.cs b/src/backend/src/iLED.Application/Common/Interfaces/IPaymentService.cs
index 6f49800..9f19760 100644
--- a/src/backend/src/iLED.Application/Common/Interfaces/IPaymentService.cs
+++ b/src/backend/src/iLED.Application/Common/Interfaces/IPaymentService.cs
@@ -17,6 +17,13 @@ public interface IPaymentService
Task GetPaymentStatusAsync(
string externalId,
CancellationToken cancellationToken = default);
+
+ ///
+ /// Refunds a PayU order (full or partial amount in PLN).
+ ///
+ Task RefundPaymentAsync(
+ PaymentRefundRequest request,
+ CancellationToken cancellationToken = default);
}
public sealed record PaymentInitiationRequest(
@@ -49,3 +56,13 @@ public sealed record PaymentStatusResult(
bool IsCompleted,
bool IsCanceled,
string? ErrorMessage);
+
+public sealed record PaymentRefundRequest(
+ string ExternalId,
+ decimal Amount,
+ string Description);
+
+public sealed record PaymentRefundResult(
+ bool Success,
+ string? RefundId,
+ string? ErrorMessage);
diff --git a/src/backend/src/iLED.Domain/AfterSales/AfterSalesAttachment.cs b/src/backend/src/iLED.Domain/AfterSales/AfterSalesAttachment.cs
new file mode 100644
index 0000000..8e5db39
--- /dev/null
+++ b/src/backend/src/iLED.Domain/AfterSales/AfterSalesAttachment.cs
@@ -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; }
+}
diff --git a/src/backend/src/iLED.Domain/AfterSales/AfterSalesRequest.cs b/src/backend/src/iLED.Domain/AfterSales/AfterSalesRequest.cs
new file mode 100644
index 0000000..f380ccd
--- /dev/null
+++ b/src/backend/src/iLED.Domain/AfterSales/AfterSalesRequest.cs
@@ -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 Lines { get; set; } = [];
+
+ public ICollection Attachments { get; set; } = [];
+}
diff --git a/src/backend/src/iLED.Domain/AfterSales/AfterSalesRequestLine.cs b/src/backend/src/iLED.Domain/AfterSales/AfterSalesRequestLine.cs
new file mode 100644
index 0000000..6c1864a
--- /dev/null
+++ b/src/backend/src/iLED.Domain/AfterSales/AfterSalesRequestLine.cs
@@ -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; }
+}
diff --git a/src/backend/src/iLED.Domain/Enums/AfterSalesEnums.cs b/src/backend/src/iLED.Domain/Enums/AfterSalesEnums.cs
new file mode 100644
index 0000000..9b0ed8e
--- /dev/null
+++ b/src/backend/src/iLED.Domain/Enums/AfterSalesEnums.cs
@@ -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,
+}
diff --git a/src/backend/src/iLED.Domain/Orders/Order.cs b/src/backend/src/iLED.Domain/Orders/Order.cs
index c741e3d..fd18dbf 100644
--- a/src/backend/src/iLED.Domain/Orders/Order.cs
+++ b/src/backend/src/iLED.Domain/Orders/Order.cs
@@ -74,8 +74,18 @@ public class Order : AuditableEntity
public Marketing.DiscountCode? DiscountCode { get; set; }
+ ///
+ /// SHA-256 hash of guest after-sales access token (plain token emailed).
+ ///
+ public string? AfterSalesAccessTokenHash { get; set; }
+
+ public DateTime? AfterSalesAccessTokenExpiresAt { get; set; }
+
public ICollection Lines { get; set; } = [];
+ public ICollection AfterSalesRequests { get; set; } =
+ [];
+
public Payments.Payment? Payment { get; set; }
public Shipping.Shipment? Shipment { get; set; }
diff --git a/src/backend/src/iLED.Infrastructure/DependencyInjection.cs b/src/backend/src/iLED.Infrastructure/DependencyInjection.cs
index 10cfaa8..a2f92a6 100644
--- a/src/backend/src/iLED.Infrastructure/DependencyInjection.cs
+++ b/src/backend/src/iLED.Infrastructure/DependencyInjection.cs
@@ -69,6 +69,11 @@ public static class DependencyInjection
services.Configure(
configuration.GetSection(ProductImageStorageOptions.SectionName));
services.AddScoped();
+ services.Configure(
+ configuration.GetSection(AfterSalesImageStorageOptions.SectionName));
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
services.AddStackExchangeRedisCache(options =>
{
diff --git a/src/backend/src/iLED.Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs b/src/backend/src/iLED.Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs
index b0074b6..0725194 100644
--- a/src/backend/src/iLED.Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs
+++ b/src/backend/src/iLED.Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs
@@ -357,6 +357,124 @@ namespace iLED.Infrastructure.Migrations
b.ToTable("StoreSettings");
});
+ modelBuilder.Entity("iLED.Domain.AfterSales.AfterSalesAttachment", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("AfterSalesRequestId")
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("OriginalFileName")
+ .HasColumnType("text");
+
+ b.Property("SortOrder")
+ .HasColumnType("integer");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Url")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AfterSalesRequestId");
+
+ b.ToTable("AfterSalesAttachments");
+ });
+
+ modelBuilder.Entity("iLED.Domain.AfterSales.AfterSalesRequest", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("AdminNote")
+ .HasColumnType("text");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("CustomerNote")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("OrderId")
+ .HasColumnType("uuid");
+
+ b.Property("PayURefundId")
+ .HasColumnType("text");
+
+ b.Property("RefundAmount")
+ .HasPrecision(18, 2)
+ .HasColumnType("numeric(18,2)");
+
+ b.Property("Resolution")
+ .HasColumnType("integer");
+
+ b.Property("ResolvedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("ResolvedByAdminId")
+ .HasColumnType("text");
+
+ b.Property("Status")
+ .HasColumnType("integer");
+
+ b.Property("Type")
+ .HasColumnType("integer");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("UserId")
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.HasIndex("OrderId");
+
+ b.HasIndex("Status");
+
+ b.ToTable("AfterSalesRequests");
+ });
+
+ modelBuilder.Entity("iLED.Domain.AfterSales.AfterSalesRequestLine", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("AfterSalesRequestId")
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("OrderLineId")
+ .HasColumnType("uuid");
+
+ b.Property("Quantity")
+ .HasColumnType("integer");
+
+ b.Property("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 =>
{
b.Property("Id")
@@ -1306,6 +1424,12 @@ namespace iLED.Infrastructure.Migrations
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
+ b.Property("AfterSalesAccessTokenExpiresAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("AfterSalesAccessTokenHash")
+ .HasColumnType("text");
+
b.Property("BillingCity")
.HasColumnType("text");
@@ -1647,6 +1771,47 @@ namespace iLED.Infrastructure.Migrations
.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 =>
{
b.HasOne("iLED.Domain.Cart.Cart", "Cart")
@@ -2147,6 +2312,13 @@ namespace iLED.Infrastructure.Migrations
b.Navigation("Order");
});
+ modelBuilder.Entity("iLED.Domain.AfterSales.AfterSalesRequest", b =>
+ {
+ b.Navigation("Attachments");
+
+ b.Navigation("Lines");
+ });
+
modelBuilder.Entity("iLED.Domain.Cart.Cart", b =>
{
b.Navigation("Items");
@@ -2214,6 +2386,8 @@ namespace iLED.Infrastructure.Migrations
modelBuilder.Entity("iLED.Domain.Orders.Order", b =>
{
+ b.Navigation("AfterSalesRequests");
+
b.Navigation("Lines");
b.Navigation("Payment");
diff --git a/src/backend/src/iLED.Infrastructure/Persistence/ApplicationDbContext.cs b/src/backend/src/iLED.Infrastructure/Persistence/ApplicationDbContext.cs
index 7812d1d..83fae63 100644
--- a/src/backend/src/iLED.Infrastructure/Persistence/ApplicationDbContext.cs
+++ b/src/backend/src/iLED.Infrastructure/Persistence/ApplicationDbContext.cs
@@ -1,4 +1,5 @@
using iLED.Domain.Admin;
+using iLED.Domain.AfterSales;
using iLED.Domain.Cart;
using iLED.Domain.Catalog;
using iLED.Domain.Identity;
@@ -48,6 +49,14 @@ public class ApplicationDbContext(DbContextOptions options
public DbSet OrderLines => Set();
+ public DbSet AfterSalesRequests => Set();
+
+ public DbSet AfterSalesRequestLines =>
+ Set();
+
+ public DbSet AfterSalesAttachments =>
+ Set();
+
public DbSet Payments => Set();
public DbSet Shipments => Set();
@@ -117,6 +126,17 @@ public class ApplicationDbContext(DbContextOptions options
IQueryable Application.Common.Interfaces.IAppDbContext.Orders =>
Orders;
+ IQueryable Application.Common.Interfaces.IAppDbContext.OrderLines =>
+ OrderLines;
+
+ IQueryable
+ Application.Common.Interfaces.IAppDbContext.AfterSalesRequests =>
+ AfterSalesRequests;
+
+ IQueryable
+ Application.Common.Interfaces.IAppDbContext.AfterSalesAttachments =>
+ AfterSalesAttachments;
+
IQueryable Application.Common.Interfaces.IAppDbContext.Payments =>
Payments;
@@ -287,5 +307,39 @@ public class ApplicationDbContext(DbContextOptions options
.WithOne(o => o.Shipment)
.HasForeignKey(s => s.OrderId)
.OnDelete(DeleteBehavior.Cascade);
+
+ builder.Entity()
+ .HasOne(r => r.Order)
+ .WithMany(o => o.AfterSalesRequests)
+ .HasForeignKey(r => r.OrderId)
+ .OnDelete(DeleteBehavior.Cascade);
+
+ builder.Entity()
+ .HasIndex(r => r.Status);
+
+ builder.Entity()
+ .HasIndex(r => r.OrderId);
+
+ builder.Entity()
+ .Property(r => r.RefundAmount)
+ .HasPrecision(18, 2);
+
+ builder.Entity()
+ .HasOne(l => l.AfterSalesRequest)
+ .WithMany(r => r.Lines)
+ .HasForeignKey(l => l.AfterSalesRequestId)
+ .OnDelete(DeleteBehavior.Cascade);
+
+ builder.Entity()
+ .HasOne(l => l.OrderLine)
+ .WithMany()
+ .HasForeignKey(l => l.OrderLineId)
+ .OnDelete(DeleteBehavior.Restrict);
+
+ builder.Entity()
+ .HasOne(a => a.AfterSalesRequest)
+ .WithMany(r => r.Attachments)
+ .HasForeignKey(a => a.AfterSalesRequestId)
+ .OnDelete(DeleteBehavior.Cascade);
}
}
diff --git a/src/backend/src/iLED.Infrastructure/Persistence/Migrations/20261019182006_AfterSalesRequests.Designer.cs b/src/backend/src/iLED.Infrastructure/Persistence/Migrations/20261019182006_AfterSalesRequests.Designer.cs
new file mode 100644
index 0000000..9580fa6
--- /dev/null
+++ b/src/backend/src/iLED.Infrastructure/Persistence/Migrations/20261019182006_AfterSalesRequests.Designer.cs
@@ -0,0 +1,2403 @@
+//
+using System;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+using iLED.Infrastructure.Persistence;
+
+#nullable disable
+
+namespace iLED.Infrastructure.Persistence.Migrations
+{
+ [DbContext(typeof(ApplicationDbContext))]
+ [Migration("20261019182006_AfterSalesRequests")]
+ partial class AfterSalesRequests
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "10.0.9")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("text");
+
+ b.Property("ConcurrencyStamp")
+ .IsConcurrencyToken()
+ .HasColumnType("text");
+
+ b.Property("Name")
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.Property("NormalizedName")
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("NormalizedName")
+ .IsUnique()
+ .HasDatabaseName("RoleNameIndex");
+
+ b.ToTable("AspNetRoles", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("ClaimType")
+ .HasColumnType("text");
+
+ b.Property("ClaimValue")
+ .HasColumnType("text");
+
+ b.Property("RoleId")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.HasIndex("RoleId");
+
+ b.ToTable("AspNetRoleClaims", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("ClaimType")
+ .HasColumnType("text");
+
+ b.Property("ClaimValue")
+ .HasColumnType("text");
+
+ b.Property("UserId")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("AspNetUserClaims", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b =>
+ {
+ b.Property("LoginProvider")
+ .HasColumnType("text");
+
+ b.Property("ProviderKey")
+ .HasColumnType("text");
+
+ b.Property("ProviderDisplayName")
+ .HasColumnType("text");
+
+ b.Property("UserId")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.HasKey("LoginProvider", "ProviderKey");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("AspNetUserLogins", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b =>
+ {
+ b.Property("UserId")
+ .HasColumnType("text");
+
+ b.Property("RoleId")
+ .HasColumnType("text");
+
+ b.HasKey("UserId", "RoleId");
+
+ b.HasIndex("RoleId");
+
+ b.ToTable("AspNetUserRoles", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b =>
+ {
+ b.Property("UserId")
+ .HasColumnType("text");
+
+ b.Property("LoginProvider")
+ .HasColumnType("text");
+
+ b.Property("Name")
+ .HasColumnType("text");
+
+ b.Property("Value")
+ .HasColumnType("text");
+
+ b.HasKey("UserId", "LoginProvider", "Name");
+
+ b.ToTable("AspNetUserTokens", (string)null);
+ });
+
+ modelBuilder.Entity("iLED.Domain.Admin.StoreSettings", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("AllegroClientId")
+ .HasColumnType("text");
+
+ b.Property("AllegroClientSecret")
+ .HasColumnType("text");
+
+ b.Property("AllegroEnabled")
+ .HasColumnType("boolean");
+
+ b.Property("AllegroLastSyncAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("AllegroSellerId")
+ .HasColumnType("text");
+
+ b.Property("AmazonAccessKey")
+ .HasColumnType("text");
+
+ b.Property("AmazonEnabled")
+ .HasColumnType("boolean");
+
+ b.Property("AmazonLastSyncAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("AmazonMarketplaceId")
+ .HasColumnType("text");
+
+ b.Property("AmazonSecretKey")
+ .HasColumnType("text");
+
+ b.Property("AmazonSellerId")
+ .HasColumnType("text");
+
+ b.Property("CarrierDhl")
+ .HasColumnType("boolean");
+
+ b.Property("CarrierDpd")
+ .HasColumnType("boolean");
+
+ b.Property("CarrierInPost")
+ .HasColumnType("boolean");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Currency")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("DefaultLocale")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("DhlApiBaseUrl")
+ .HasColumnType("text");
+
+ b.Property("DhlApiKey")
+ .HasColumnType("text");
+
+ b.Property("DhlApiSecret")
+ .HasColumnType("text");
+
+ b.Property("DhlWidgetUrl")
+ .HasColumnType("text");
+
+ b.Property("DpdApiBaseUrl")
+ .HasColumnType("text");
+
+ b.Property("DpdFid")
+ .HasColumnType("text");
+
+ b.Property("DpdLogin")
+ .HasColumnType("text");
+
+ b.Property("DpdPassword")
+ .HasColumnType("text");
+
+ b.Property("DpdWidgetUrl")
+ .HasColumnType("text");
+
+ b.Property("EmailFromAddress")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("EmailFromName")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("EmailReplyTo")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("IfirmaApiKey")
+ .HasColumnType("text");
+
+ b.Property("IfirmaAutoInvoice")
+ .HasColumnType("boolean");
+
+ b.Property("IfirmaEnabled")
+ .HasColumnType("boolean");
+
+ b.Property("IfirmaInvoiceSeries")
+ .HasColumnType("text");
+
+ b.Property("IfirmaLogin")
+ .HasColumnType("text");
+
+ b.Property("InPostGeowidgetConfig")
+ .HasColumnType("text");
+
+ b.Property("InPostGeowidgetPublicToken")
+ .HasColumnType("text");
+
+ b.Property("InPostGeowidgetSandboxToken")
+ .HasColumnType("text");
+
+ b.Property("InPostGeowidgetSdkBaseUrl")
+ .HasColumnType("text");
+
+ b.Property("InPostOrganizationId")
+ .HasColumnType("text");
+
+ b.Property("InPostShipXBaseUrl")
+ .HasColumnType("text");
+
+ b.Property("InPostShipXToken")
+ .HasColumnType("text");
+
+ b.Property("LegalName")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("LocaleCs")
+ .HasColumnType("boolean");
+
+ b.Property("LocaleDe")
+ .HasColumnType("boolean");
+
+ b.Property("LocaleEn")
+ .HasColumnType("boolean");
+
+ b.Property("LocalePl")
+ .HasColumnType("boolean");
+
+ b.Property("LowStockThreshold")
+ .HasColumnType("integer");
+
+ b.Property("PayUClientId")
+ .HasColumnType("text");
+
+ b.Property("PayUClientSecret")
+ .HasColumnType("text");
+
+ b.Property("PayUContinueUrl")
+ .HasColumnType("text");
+
+ b.Property("PayUNotifyUrl")
+ .HasColumnType("text");
+
+ b.Property("PayUOauthUrl")
+ .HasColumnType("text");
+
+ b.Property("PayUOrdersUrl")
+ .HasColumnType("text");
+
+ b.Property("PayUPosId")
+ .HasColumnType("text");
+
+ b.Property("PayUSecondKey")
+ .HasColumnType("text");
+
+ b.Property("PayUUseSandbox")
+ .HasColumnType("boolean");
+
+ b.Property("ShippingUseSandbox")
+ .HasColumnType("boolean");
+
+ b.Property("StoreName")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("StorefrontUrl")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("SupportEmail")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.HasKey("Id");
+
+ b.ToTable("StoreSettings");
+ });
+
+ modelBuilder.Entity("iLED.Domain.AfterSales.AfterSalesAttachment", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("AfterSalesRequestId")
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("OriginalFileName")
+ .HasColumnType("text");
+
+ b.Property("SortOrder")
+ .HasColumnType("integer");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Url")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AfterSalesRequestId");
+
+ b.ToTable("AfterSalesAttachments");
+ });
+
+ modelBuilder.Entity("iLED.Domain.AfterSales.AfterSalesRequest", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("AdminNote")
+ .HasColumnType("text");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("CustomerNote")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("OrderId")
+ .HasColumnType("uuid");
+
+ b.Property("PayURefundId")
+ .HasColumnType("text");
+
+ b.Property("RefundAmount")
+ .HasPrecision(18, 2)
+ .HasColumnType("numeric(18,2)");
+
+ b.Property("Resolution")
+ .HasColumnType("integer");
+
+ b.Property("ResolvedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("ResolvedByAdminId")
+ .HasColumnType("text");
+
+ b.Property("Status")
+ .HasColumnType("integer");
+
+ b.Property("Type")
+ .HasColumnType("integer");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("UserId")
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.HasIndex("OrderId");
+
+ b.HasIndex("Status");
+
+ b.ToTable("AfterSalesRequests");
+ });
+
+ modelBuilder.Entity("iLED.Domain.AfterSales.AfterSalesRequestLine", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("AfterSalesRequestId")
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("OrderLineId")
+ .HasColumnType("uuid");
+
+ b.Property("Quantity")
+ .HasColumnType("integer");
+
+ b.Property("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 =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("SessionId")
+ .HasColumnType("text");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("UserId")
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.ToTable("Carts");
+ });
+
+ modelBuilder.Entity("iLED.Domain.Cart.CartItem", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CartId")
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("ProductId")
+ .HasColumnType("uuid");
+
+ b.Property("ProductVariantId")
+ .HasColumnType("uuid");
+
+ b.Property("Quantity")
+ .HasColumnType("integer");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.HasKey("Id");
+
+ b.HasIndex("CartId");
+
+ b.ToTable("CartItems");
+ });
+
+ modelBuilder.Entity("iLED.Domain.Cart.FavoriteItem", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("ProductId")
+ .HasColumnType("uuid");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("UserId")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ProductId");
+
+ b.ToTable("FavoriteItems");
+ });
+
+ modelBuilder.Entity("iLED.Domain.Catalog.Category", b =>
+ {
+ b.Property