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
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