Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 7 additions & 35 deletions frontend/app/api/shop/admin/orders/[id]/refund/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import {
AdminUnauthorizedError,
requireAdminApi,
} from '@/lib/auth/admin';
import { getMonobankConfig } from '@/lib/env/monobank';
import { readPositiveIntEnv } from '@/lib/env/readPositiveIntEnv';
import { logError, logWarn } from '@/lib/logging';
import { requireAdminCsrf } from '@/lib/security/admin-csrf';
Expand Down Expand Up @@ -143,44 +142,17 @@ export async function POST(
.limit(1);

if (targetOrder?.paymentProvider === 'monobank') {
const { refundEnabled } = getMonobankConfig();
if (!refundEnabled) {
logWarn('admin_orders_refund_disabled', {
...baseMeta,
code: 'REFUND_DISABLED',
orderId: orderIdForLog,
durationMs: Date.now() - startedAtMs,
});

return noStoreJson(
{ code: 'REFUND_DISABLED', message: 'Refunds are disabled.' },
{ status: 409 }
);
}

const { requestMonobankFullRefund } =
await import('@/lib/services/orders/monobank-refund');
const result = await requestMonobankFullRefund({
logWarn('admin_orders_refund_disabled', {
...baseMeta,
code: 'REFUND_DISABLED',
orderId: orderIdForLog,
requestId,
durationMs: Date.now() - startedAtMs,
});

const orderSummary = orderSummarySchema.parse(result.order);

return noStoreJson({
success: true,
order: {
...orderSummary,
createdAt:
orderSummary.createdAt instanceof Date
? orderSummary.createdAt.toISOString()
: String(orderSummary.createdAt),
},
refund: {
...result.refund,
deduped: result.deduped,
},
});
code: 'REFUND_DISABLED',
message: 'Refunds are disabled.',
}, { status: 409 });
}

const order = await refundOrder(orderIdForLog, { requestedBy: 'admin' });
Expand Down
23 changes: 20 additions & 3 deletions frontend/app/api/shop/admin/orders/reconcile-stale/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,10 @@ import {
verifyCsrfToken,
} from '@/lib/security/csrf';
import { guardBrowserSameOrigin } from '@/lib/security/origin';
import { restockStalePendingOrders } from '@/lib/services/orders';
import {
reconcileStaleStripeRefundOrders,
restockStalePendingOrders,
} from '@/lib/services/orders';
export const runtime = 'nodejs';

function noStoreJson(body: unknown, init?: { status?: number }) {
Expand Down Expand Up @@ -93,18 +96,32 @@ export async function POST(request: NextRequest) {
return noStoreJson({ code: 'CSRF_INVALID' }, { status: 403 });
}

const processed = await restockStalePendingOrders({
const processedStalePending = await restockStalePendingOrders({
olderThanMinutes: DEFAULT_STALE_MINUTES,
});
const processedStripeRefundRecovery =
await reconcileStaleStripeRefundOrders({
olderThanMinutes: DEFAULT_STALE_MINUTES,
});
const processed = processedStalePending + processedStripeRefundRecovery;
logInfo('admin_reconcile_stale_succeeded', {
...baseMeta,
code: 'OK',
processed,
processedStalePending,
processedStripeRefundRecovery,
olderThanMinutes: DEFAULT_STALE_MINUTES,
durationMs: Date.now() - startedAtMs,
});

return noStoreJson({ processed }, { status: 200 });
return noStoreJson(
{
processed,
processedStalePending,
processedStripeRefundRecovery,
},
{ status: 200 }
);
} catch (error) {
if (error instanceof AdminApiDisabledError) {
logWarn('admin_reconcile_stale_admin_api_disabled', {
Expand Down
18 changes: 3 additions & 15 deletions frontend/app/api/shop/admin/returns/[id]/refund/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ function mapInvalidPayloadStatus(code: string): number {
if (code === 'RETURN_NOT_FOUND') return 404;
if (
code === 'RETURN_TRANSITION_INVALID' ||
code === 'RETURN_REFUND_DISABLED' ||
code === 'RETURN_REFUND_STATE_INVALID' ||
code === 'RETURN_REFUND_PROVIDER_UNSUPPORTED' ||
code === 'RETURN_REFUND_PAYMENT_STATUS_INVALID'
Expand Down Expand Up @@ -70,25 +71,12 @@ export async function POST(
}
returnRequestIdForLog = parsed.data.id;

const result = await refundReturnRequest({
await refundReturnRequest({
returnRequestId: returnRequestIdForLog,
actorUserId: typeof admin.id === 'string' ? admin.id : null,
requestId,
});

return noStoreJson({
success: true,
changed: result.changed,
returnRequest: {
...result.row,
approvedAt: result.row.approvedAt?.toISOString() ?? null,
rejectedAt: result.row.rejectedAt?.toISOString() ?? null,
receivedAt: result.row.receivedAt?.toISOString() ?? null,
refundedAt: result.row.refundedAt?.toISOString() ?? null,
createdAt: result.row.createdAt.toISOString(),
updatedAt: result.row.updatedAt.toISOString(),
},
});
throw new Error('refundReturnRequest unexpectedly resolved');
} catch (error) {
if (error instanceof AdminApiDisabledError) {
return noStoreJson({ code: 'ADMIN_API_DISABLED' }, 403);
Expand Down
152 changes: 96 additions & 56 deletions frontend/app/api/shop/checkout/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ import {
paymentProviderValues,
type PaymentStatus,
paymentStatusValues,
resolveCheckoutProviderCandidates,
resolveDefaultMethodForProvider,
} from '@/lib/shop/payments';
import { resolveRequestLocale } from '@/lib/shop/request-locale';
import {
Expand Down Expand Up @@ -256,7 +258,7 @@ function mapMonobankCheckoutError(error: unknown) {
return {
code: 'PRICE_CONFIG_ERROR',
message: getErrorMessage(error, 'Price configuration error.'),
status: 422,
status: 400,
details:
error instanceof PriceConfigError
? {
Expand Down Expand Up @@ -845,72 +847,110 @@ export async function POST(request: NextRequest) {
payloadForValidation = rest;
}

const selectedProvider: CheckoutRequestedProvider =
requestedProvider ?? 'stripe';
// Explicit default table (locked): stripe -> stripe_card, monobank -> monobank_invoice.
const defaultMethod: PaymentMethod =
selectedProvider === 'monobank' ? 'monobank_invoice' : 'stripe_card';
const selectedMethod: PaymentMethod = requestedMethod ?? defaultMethod;
const selectedCurrency =
selectedProvider === 'monobank' ? 'UAH' : resolveCurrencyFromLocale(locale);

if (selectedProvider === 'monobank') {
payloadForValidation = stripMonobankClientMoneyFields(payloadForValidation);
}

const localeCurrency = resolveCurrencyFromLocale(locale);
const paymentsEnabled =
(process.env.PAYMENTS_ENABLED ?? '').trim() === 'true';

const stripeCheckoutAvailable = isStripePaymentsEnabled({
requirePublishableKey: true,
});
const checkoutPaymentProvider: PaymentProvider =
selectedProvider === 'monobank'
? 'monobank'
: stripeCheckoutAvailable
? 'stripe'
: 'none';
let monobankCheckoutAvailable = false;
try {
monobankCheckoutAvailable = paymentsEnabled && isMonobankEnabled();
} catch (error) {
logError('monobank_env_invalid', error, {
...baseMeta,
code: 'MONOBANK_ENV_INVALID',
});
}

const stripeExplicitlyRequested =
requestedProvider === 'stripe' || requestedMethod === 'stripe_card';
const checkoutProviderCandidates = resolveCheckoutProviderCandidates({
requestedProvider,
requestedMethod,
currency: localeCurrency,
});
const selectedProvider =
checkoutProviderCandidates.find(candidate =>
candidate === 'stripe'
? stripeCheckoutAvailable
: monobankCheckoutAvailable
) ?? null;

const fallbackProvider =
selectedProvider ?? checkoutProviderCandidates[0] ?? null;
const selectedCurrency =
fallbackProvider === 'monobank' ? 'UAH' : localeCurrency;
const selectedMethod =
requestedMethod ??
(fallbackProvider
? resolveDefaultMethodForProvider(fallbackProvider, selectedCurrency)
: null);

if (fallbackProvider === 'monobank') {
payloadForValidation = stripMonobankClientMoneyFields(payloadForValidation);
}

const stripeRequestedButUnavailable =
stripeExplicitlyRequested && !stripeCheckoutAvailable;
checkoutProviderCandidates.length === 1 &&
checkoutProviderCandidates[0] === 'stripe' &&
!stripeCheckoutAvailable;

if (selectedProvider === 'monobank') {
let enabled = false;
if (!selectedMethod) {
logWarn('checkout_provider_unavailable', {
...baseMeta,
code: 'PAYMENTS_DISABLED',
requestedProvider,
requestedMethod,
localeCurrency,
candidates: checkoutProviderCandidates,
stripeCheckoutAvailable,
monobankCheckoutAvailable,
});

try {
enabled = isMonobankEnabled();
} catch (error) {
logError('monobank_env_invalid', error, {
...baseMeta,
code: 'MONOBANK_ENV_INVALID',
});
enabled = false;
}
return errorResponse(
'PSP_UNAVAILABLE',
'Payment provider unavailable.',
503
);
}

if (!enabled) {
logWarn('provider_disabled', {
requestedProvider: 'monobank',
requestId,
});
if (!selectedProvider && !stripeRequestedButUnavailable) {
logWarn('checkout_provider_unavailable', {
...baseMeta,
code: 'PAYMENTS_DISABLED',
requestedProvider,
requestedMethod,
localeCurrency,
candidates: checkoutProviderCandidates,
stripeCheckoutAvailable,
monobankCheckoutAvailable,
});

return errorResponse('INVALID_REQUEST', 'Invalid request.', 422);
}
return errorResponse(
'PSP_UNAVAILABLE',
'Payment provider unavailable.',
503
);
}

if (!paymentsEnabled) {
logWarn('monobank_payments_disabled', {
...baseMeta,
code: 'PAYMENTS_DISABLED',
});
const resolvedProvider = selectedProvider ?? fallbackProvider;
if (!resolvedProvider) {
logWarn('checkout_provider_unavailable', {
...baseMeta,
code: 'PAYMENTS_DISABLED',
requestedProvider,
requestedMethod,
localeCurrency,
candidates: checkoutProviderCandidates,
stripeCheckoutAvailable,
monobankCheckoutAvailable,
});

return errorResponse(
'PSP_UNAVAILABLE',
'Payment provider unavailable.',
503
);
}
return errorResponse(
'PSP_UNAVAILABLE',
'Payment provider unavailable.',
503
);
}

if (
Expand All @@ -922,13 +962,13 @@ export async function POST(request: NextRequest) {

if (
!isMethodAllowed({
provider: selectedProvider,
provider: resolvedProvider,
method: selectedMethod,
currency: selectedCurrency,
flags: { monobankGooglePayEnabled: isMonobankGooglePayEnabled() },
})
) {
if (selectedProvider === 'monobank') {
if (resolvedProvider === 'monobank') {
return errorResponse('INVALID_REQUEST', 'Invalid request.', 422);
}

Expand All @@ -946,7 +986,7 @@ export async function POST(request: NextRequest) {
) {
payloadForValidation = {
...(payloadForValidation as Record<string, unknown>),
paymentProvider: selectedProvider,
paymentProvider: fallbackProvider,
paymentMethod: selectedMethod,
paymentCurrency: selectedCurrency,
};
Expand Down Expand Up @@ -1133,7 +1173,7 @@ export async function POST(request: NextRequest) {
country: country ?? null,
shipping: shipping ?? null,
legalConsent: legalConsent ?? null,
paymentProvider: checkoutPaymentProvider,
paymentProvider: resolvedProvider,
paymentMethod: selectedMethod,
}));

Expand Down
Loading
Loading