Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
9 changes: 4 additions & 5 deletions frontend/lib/env/server-env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,14 +44,14 @@ const GENERATED_FALLBACK_KEYS = new Set([
'ENABLE_ADMIN_API',
'NEXT_PUBLIC_ENABLE_ADMIN',
'SHOP_STATUS_TOKEN_SECRET',
'SHOP_MONOBANK_GPAY_ENABLED',
'APP_ORIGIN',
'APP_ADDITIONAL_ORIGINS',
'GMAIL_USER',
'GMAIL_APP_PASSWORD',
'EMAIL_FROM',
]);


function canUseGeneratedFallback(key: string): boolean {
return GENERATED_FALLBACK_KEYS.has(key);
}
Expand All @@ -63,12 +63,11 @@ export function readServerEnv(key: string): string | undefined {
const fromNetlify = readFromNetlifyEnv(key);
if (fromNetlify) return fromNetlify;

if (!canUseGeneratedFallback(key)) return undefined;
return readFromGeneratedRuntimeEnv(key);

if (!canUseGeneratedFallback(key)) return undefined;
return readFromGeneratedRuntimeEnv(key);
}

function readFromGeneratedRuntimeEnv(key: string): string | undefined {
const value = RUNTIME_ENV[key];
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
}
}
45 changes: 42 additions & 3 deletions frontend/lib/services/orders/checkout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@ import {
getShopShippingFlags,
NovaPoshtaConfigError,
} from '@/lib/env/nova-poshta';
import { readServerEnv } from '@/lib/env/server-env';
import { logError, logWarn } from '@/lib/logging';
import { writePaymentEvent } from '@/lib/services/shop/events/write-payment-event';
import { resolveShippingAvailability } from '@/lib/services/shop/shipping/availability';
import {
type CheckoutShippingQuote,
Expand Down Expand Up @@ -80,6 +82,42 @@ export async function findExistingCheckoutOrderByIdempotencyKey(
return getOrderByIdempotencyKey(db, idempotencyKey);
}

async function writeOrderCreatedCanonicalEvent(
order: OrderSummaryWithMinor
): Promise<void> {
await writePaymentEvent({
orderId: order.id,
provider: order.paymentProvider,
eventName: 'order_created',
eventSource: 'checkout',
amountMinor: order.totalAmountMinor,
currency: order.currency,
payload: {
orderId: order.id,
totalAmountMinor: order.totalAmountMinor,
currency: order.currency,
paymentProvider: order.paymentProvider,
paymentStatus: order.paymentStatus,
fulfillmentStage: order.fulfillmentStage,
createdAt: order.createdAt.toISOString(),
},
});
}

async function ensureOrderCreatedCanonicalEvent(
order: OrderSummaryWithMinor
): Promise<void> {
try {
await writeOrderCreatedCanonicalEvent(order);
} catch (error) {
logWarn('checkout_order_created_event_write_failed', {
orderId: order.id,
code: 'ORDER_CREATED_EVENT_WRITE_FAILED',
message: error instanceof Error ? error.message : String(error),
});
}
}

async function getProductsForCheckout(
productIds: string[],
currency: Currency
Expand Down Expand Up @@ -697,9 +735,7 @@ function priceItems(
}

function isMonobankGooglePayEnabled(): boolean {
const raw = (process.env.SHOP_MONOBANK_GPAY_ENABLED ?? '')
.trim()
.toLowerCase();
const raw = readServerEnv('SHOP_MONOBANK_GPAY_ENABLED')?.toLowerCase() ?? '';
return raw === 'true' || raw === '1' || raw === 'yes' || raw === 'on';
}

Expand Down Expand Up @@ -1135,6 +1171,7 @@ export async function createOrderWithItems({
snapshot: preparedShipping.snapshot,
});
}
await ensureOrderCreatedCanonicalEvent(existing);
return {
order: existing,
isNew: false,
Expand Down Expand Up @@ -1345,6 +1382,7 @@ export async function createOrderWithItems({
snapshot: preparedShipping.snapshot,
});
}
await ensureOrderCreatedCanonicalEvent(existingOrder);
return {
order: existingOrder,
isNew: false,
Expand Down Expand Up @@ -1494,5 +1532,6 @@ export async function createOrderWithItems({
}

const order = await getOrderById(orderId);
await ensureOrderCreatedCanonicalEvent(order);
return { order, isNew: true, totalCents: orderTotalCents };
}
120 changes: 118 additions & 2 deletions frontend/lib/services/orders/restock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,15 @@ import { and, eq, isNull, lt, ne, or } from 'drizzle-orm';
import { db } from '@/db';
import { inventoryMoves, orders } from '@/db/schema/shop';
import { logWarn } from '@/lib/logging';
import { buildPaymentEventDedupeKey } from '@/lib/services/shop/events/dedupe-key';
import { writePaymentEvent } from '@/lib/services/shop/events/write-payment-event';
import { closeShippingPipelineForOrder } from '@/lib/services/shop/shipping/pipeline-shutdown';
import { isOrderNonPaymentStatusTransitionAllowed } from '@/lib/services/shop/transitions/order-state';
import { type PaymentStatus } from '@/lib/shop/payments';

import { OrderNotFoundError, OrderStateInvalidError } from '../errors';
import { applyReleaseMove } from '../inventory';
import { resolvePaymentProvider } from './_shared';
import { type OrderRow, resolvePaymentProvider } from './_shared';
import { guardedPaymentStatusUpdate } from './payment-state';

const PAYMENT_STATUS_KEY = 'paymentStatus' as const;
Expand Down Expand Up @@ -94,6 +96,99 @@ function validateRestockTransition(
}
}

type OrderCanceledNotificationState = Pick<
OrderRow,
| 'id'
| 'totalAmountMinor'
| 'currency'
| 'paymentProvider'
| 'paymentIntentId'
| 'paymentStatus'
| 'status'
| 'inventoryStatus'
| 'stockRestored'
| 'restockedAt'
| 'shippingStatus'
>;

async function loadOrderCanceledNotificationState(
orderId: string
): Promise<OrderCanceledNotificationState | null> {
const [row] = await db
.select({
id: orders.id,
totalAmountMinor: orders.totalAmountMinor,
currency: orders.currency,
paymentProvider: orders.paymentProvider,
paymentIntentId: orders.paymentIntentId,
paymentStatus: orders.paymentStatus,
status: orders.status,
inventoryStatus: orders.inventoryStatus,
stockRestored: orders.stockRestored,
restockedAt: orders.restockedAt,
shippingStatus: orders.shippingStatus,
})
.from(orders)
.where(eq(orders.id, orderId))
.limit(1);

return (row as OrderCanceledNotificationState | undefined) ?? null;
}

function buildOrderCanceledEventDedupeKey(orderId: string): string {
return buildPaymentEventDedupeKey({
orderId,
eventName: 'order_canceled',
status: 'CANCELED',
});
}

async function ensureOrderCanceledCanonicalEvent(args: {
orderId: string;
ensuredBy: string;
}): Promise<void> {
const state = await loadOrderCanceledNotificationState(args.orderId);
if (
!state ||
state.status !== 'CANCELED' ||
state.inventoryStatus !== 'released' ||
!state.stockRestored
) {
return;
}

try {
await writePaymentEvent({
orderId: state.id,
provider: resolvePaymentProvider(state),
eventName: 'order_canceled',
eventSource: 'order_restock',
eventRef: null,
amountMinor: state.totalAmountMinor,
currency: state.currency,
payload: {
orderId: state.id,
totalAmountMinor: state.totalAmountMinor,
currency: state.currency,
paymentProvider: state.paymentProvider,
paymentStatus: state.paymentStatus,
orderStatus: state.status,
inventoryStatus: state.inventoryStatus,
shippingStatus: state.shippingStatus,
restockedAt: state.restockedAt?.toISOString() ?? null,
ensuredBy: args.ensuredBy,
},
dedupeKey: buildOrderCanceledEventDedupeKey(state.id),
});
} catch (error) {
logWarn('order_canceled_event_write_failed', {
orderId: args.orderId,
ensuredBy: args.ensuredBy,
error: error instanceof Error ? error.message : String(error),
});
}
}

export async function restockOrder(
orderId: string,
options?: RestockOptions
Expand Down Expand Up @@ -127,8 +222,15 @@ export async function restockOrder(
order.inventoryStatus === 'released' ||
order.stockRestored ||
order.restockedAt !== null
)
) {
if (reason === 'canceled' && order.status === 'CANCELED') {
await ensureOrderCanceledCanonicalEvent({
orderId,
ensuredBy: 'restock_replay',
});
}
return;
}

if (reason) {
await closeShippingPipelineForOrder({
Expand Down Expand Up @@ -236,6 +338,13 @@ export async function restockOrder(
});
}

if (reason === 'canceled') {
await ensureOrderCanceledCanonicalEvent({
orderId,
ensuredBy: 'restock_finalize_orphan',
});
}

return;
}

Expand Down Expand Up @@ -390,4 +499,11 @@ export async function restockOrder(
extraWhere: eq(orders.restockedAt, finalizedAt),
});
}

if (reason === 'canceled') {
await ensureOrderCanceledCanonicalEvent({
orderId,
ensuredBy: 'restock_finalize',
});
}
}
42 changes: 36 additions & 6 deletions frontend/lib/services/shop/notifications/outbox-worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,22 @@ type OutboxClaimedRow = {
type PreviewCountRow = { total: number };

type NotificationRecipientLookupRow = {
order_user_id: string | null;
shipping_email: string | null;
user_email: string | null;
};

type NotificationRecipient = {
email: string;
};
type NotificationRecipient =
| {
kind: 'resolved';
email: string;
}
| {
kind: 'missing';
missingCode:
| 'NOTIFICATION_GUEST_RECIPIENT_MISSING'
| 'NOTIFICATION_RECIPIENT_MISSING';
};

export type NotificationWorkerRunArgs = {
runId: string;
Expand Down Expand Up @@ -135,6 +144,7 @@ async function loadNotificationRecipient(
): Promise<NotificationRecipient | null> {
const res = await db.execute<NotificationRecipientLookupRow>(sql`
select
o.user_id::text as order_user_id,
nullif(trim(os.shipping_address #>> '{recipient,email}'), '') as shipping_email,
nullif(trim(u.email), '') as user_email
from orders o
Expand All @@ -149,15 +159,25 @@ async function loadNotificationRecipient(

const shippingEmail = normalizeEmailOrNull(row.shipping_email);
if (shippingEmail) {
return { email: shippingEmail };
return { kind: 'resolved', email: shippingEmail };
}

const userEmail = normalizeEmailOrNull(row.user_email);
if (userEmail) {
return { email: userEmail };
return { kind: 'resolved', email: userEmail };
}

if (!row.order_user_id) {
return {
kind: 'missing',
missingCode: 'NOTIFICATION_GUEST_RECIPIENT_MISSING',
};
}

return null;
return {
kind: 'missing',
missingCode: 'NOTIFICATION_RECIPIENT_MISSING',
};
}

function toNotificationSendError(error: unknown): NotificationSendError {
Expand Down Expand Up @@ -205,6 +225,16 @@ async function sendNotification(row: OutboxClaimedRow): Promise<void> {
);
}

if (recipient.kind === 'missing') {
throw new NotificationSendError(
recipient.missingCode,
recipient.missingCode === 'NOTIFICATION_GUEST_RECIPIENT_MISSING'
? 'Guest notification recipient email is missing from persisted shipping data.'
: 'Notification recipient email is missing for order.',
false
);
}

const template = renderShopNotificationTemplate({
templateKey: row.template_key as ShopNotificationTemplateKey,
orderId: row.order_id,
Expand Down
Loading
Loading