Add after-sales returns and claims with PayU refunds and guest magic links.
continuous-integration/drone/push Build is passing
continuous-integration/drone/push Build is passing
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
2623664504
commit
f41d12b64f
41 files changed
+5316
-6
No files matched your search
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+2403
File diff suppressed because it is too large.
Load diff
+154
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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: () =>
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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="/"
|
||||||
|
|||||||
Reference in new issue
Block a user