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
21 changes: 18 additions & 3 deletions frontend/app/[locale]/shop/admin/products/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,22 @@ function formatDate(value: Date | null, locale: string) {
return value.toLocaleDateString(locale);
}

function safeFromDbMoney(value: unknown): number | null {
function safeFromDbMoney(
value: unknown,
ctx: { productId: string; currency: string }
): number | null {
// expected case for leftJoin: missing price row
if (value == null) return null;

try {
return fromDbMoney(value);
} catch {
} catch (err) {
console.warn('[admin products] fromDbMoney failed', {
...ctx,
valueType: typeof value,
value,
error: err instanceof Error ? err.message : String(err),
});
return null;
}
}
Expand Down Expand Up @@ -108,7 +120,10 @@ export default async function AdminProductsPage({

<tbody className="divide-y divide-border">
{rows.map(row => {
const priceMinor = safeFromDbMoney(row.price);
const priceMinor = safeFromDbMoney(row.price, {
productId: row.id,
currency: displayCurrency,
});

return (
<tr key={row.id} className="hover:bg-muted/50">
Expand Down
3 changes: 2 additions & 1 deletion frontend/app/api/shop/admin/products/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@ type InvalidPricesJsonError = {
function isInvalidPricesJsonError(
value: SaleRuleViolation | InvalidPricesJsonError | null
): value is InvalidPricesJsonError {
return !!value && (value as any).code === 'INVALID_PRICES_JSON';
if (!value || typeof value !== 'object') return false;
return (value as Record<string, unknown>).code === 'INVALID_PRICES_JSON';
}

function findSaleRuleViolation(input: any): SaleRuleViolation | null {
Expand Down
3 changes: 2 additions & 1 deletion frontend/app/api/shop/admin/products/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ type InvalidPricesJsonError = {
function isInvalidPricesJsonError(
value: SaleRuleViolation | InvalidPricesJsonError | null
): value is InvalidPricesJsonError {
return !!value && (value as any).code === 'INVALID_PRICES_JSON';
if (!value || typeof value !== 'object') return false;
return (value as Record<string, unknown>).code === 'INVALID_PRICES_JSON';
}

function findSaleRuleViolation(input: any): SaleRuleViolation | null {
Expand Down
67 changes: 56 additions & 11 deletions frontend/app/api/shop/webhooks/stripe/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,17 @@ function buildPspMetadata(params: {
};
}

function shouldRestockFromWebhook(order: {
stockRestored: boolean | null;
inventoryStatus: string | null;
}) {
// Webhook-level gate: avoid unnecessary calls when we already restored stock
// or inventory has already been released.
if (order.stockRestored === true) return false;
if (order.inventoryStatus === 'released') return false;
return true;
}

export async function POST(request: NextRequest) {
let rawBody: string;

Expand Down Expand Up @@ -310,6 +321,8 @@ export async function POST(request: NextRequest) {
currency: orders.currency,
paymentStatus: orders.paymentStatus,
status: orders.status,
stockRestored: orders.stockRestored,
inventoryStatus: orders.inventoryStatus,
})
.from(orders)
.where(eq(orders.id, resolvedOrderId))
Expand Down Expand Up @@ -504,7 +517,7 @@ export async function POST(request: NextRequest) {
paymentIntent?.status ??
'payment_failed';

const updated = await db
await db
.update(orders)
.set({
paymentStatus: 'failed',
Expand All @@ -521,10 +534,11 @@ export async function POST(request: NextRequest) {
charge: chargeForIntent,
}),
})
.where(and(eq(orders.id, order.id), ne(orders.paymentStatus, 'failed')))
.returning({ id: orders.id });
.where(
and(eq(orders.id, order.id), ne(orders.paymentStatus, 'failed'))
);

if (updated.length > 0) {
if (shouldRestockFromWebhook(order)) {
await restockOrder(order.id, { reason: 'failed' });
}

Expand Down Expand Up @@ -554,7 +568,7 @@ export async function POST(request: NextRequest) {
paymentIntent?.status ??
'canceled';

const updated = await db
await db
.update(orders)
.set({
paymentStatus: 'failed',
Expand All @@ -571,10 +585,11 @@ export async function POST(request: NextRequest) {
charge: chargeForIntent,
}),
})
.where(and(eq(orders.id, order.id), ne(orders.paymentStatus, 'failed')))
.returning({ id: orders.id });
.where(
and(eq(orders.id, order.id), ne(orders.paymentStatus, 'failed'))
);

if (updated.length > 0) {
if (shouldRestockFromWebhook(order)) {
await restockOrder(order.id, { reason: 'canceled' });
}

Expand Down Expand Up @@ -611,16 +626,44 @@ export async function POST(request: NextRequest) {

if (eventType === 'charge.refunded') {
const effectiveCharge = charge;

const amt =
typeof (effectiveCharge as any)?.amount === 'number'
? (effectiveCharge as any).amount
: null;
const refunded =

let cumulativeRefunded: number | null =
typeof (effectiveCharge as any)?.amount_refunded === 'number'
? (effectiveCharge as any).amount_refunded
: null;

isFullRefund = amt != null && refunded != null && refunded === amt;
// Fallback: sum refunds list if amount_refunded is missing
if (
cumulativeRefunded == null &&
Array.isArray((effectiveCharge as any)?.refunds?.data)
) {
const list = (effectiveCharge as any).refunds.data as any[];
const sumFromList = list.reduce((sum, r) => {
const a = typeof r?.amount === 'number' ? r.amount : 0;
return sum + a;
}, 0);

const currentAmt =
typeof (refund as any)?.amount === 'number'
? (refund as any).amount
: 0;

const hasCurrent =
refund?.id && list.some(r => r?.id && r.id === refund.id);

cumulativeRefunded = sumFromList + (hasCurrent ? 0 : currentAmt);
}

if (amt == null || cumulativeRefunded == null) {
throw new Error('REFUND_FULLNESS_UNDETERMINED');
}
Comment on lines +640 to +664
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

git ls-files frontend/app/api/shop/webhooks/stripe/ | head -20

Repository: DevLoversTeam/devlovers.net

Length of output: 117


🏁 Script executed:

wc -l frontend/app/api/shop/webhooks/stripe/route.ts

Repository: DevLoversTeam/devlovers.net

Length of output: 121


🏁 Script executed:

# Search for the charge.refunded event handler around line 609
sed -n '600,620p' frontend/app/api/shop/webhooks/stripe/route.ts

Repository: DevLoversTeam/devlovers.net

Length of output: 682


🏁 Script executed:

# Also search for the full refund determination logic
sed -n '600,680p' frontend/app/api/shop/webhooks/stripe/route.ts

Repository: DevLoversTeam/devlovers.net

Length of output: 2961


🏁 Script executed:

# Search for refundObject assignment/definition
sed -n '1,610p' frontend/app/api/shop/webhooks/stripe/route.ts | grep -n "refundObject" | head -20

Repository: DevLoversTeam/devlovers.net

Length of output: 454


🏁 Script executed:

# Get more context around where refundObject is used
sed -n '570,610p' frontend/app/api/shop/webhooks/stripe/route.ts

Repository: DevLoversTeam/devlovers.net

Length of output: 1114


🏁 Script executed:

# Get context around refundObject initialization
sed -n '160,190p' frontend/app/api/shop/webhooks/stripe/route.ts

Repository: DevLoversTeam/devlovers.net

Length of output: 963


🏁 Script executed:

# Check the event type and object type logic
sed -n '140,180p' frontend/app/api/shop/webhooks/stripe/route.ts

Repository: DevLoversTeam/devlovers.net

Length of output: 1425


🏁 Script executed:

# Check if there's any refund amount data available in charge.refunded event
sed -n '609,675p' frontend/app/api/shop/webhooks/stripe/route.ts | head -80

Repository: DevLoversTeam/devlovers.net

Length of output: 2663


🏁 Script executed:

# Search for any comments or documentation about Stripe charge.refunded event structure
grep -n "charge.refunded" frontend/app/api/shop/webhooks/stripe/route.ts | head -20

Repository: DevLoversTeam/devlovers.net

Length of output: 223


🏁 Script executed:

# Check if there's any information about refund amount in the charge object itself during charge.refunded
sed -n '600,650p' frontend/app/api/shop/webhooks/stripe/route.ts | grep -E "(amount|refund)" -A 2 -B 2

Repository: DevLoversTeam/devlovers.net

Length of output: 1712


🏁 Script executed:

# Look for how charge.refunded is documented or any comments about Stripe API structure
grep -n "amount_refunded\|refunds.data" frontend/app/api/shop/webhooks/stripe/route.ts

Repository: DevLoversTeam/devlovers.net

Length of output: 687


🏁 Script executed:

# Check Stripe API documentation comments or any related notes in the codebase
grep -n "Stripe\|refund\|amount_refunded" frontend/app/api/shop/webhooks/stripe/route.ts | grep -i "comment\|note\|todo\|bug\|fixme" -A 2 -B 2

Repository: DevLoversTeam/devlovers.net

Length of output: 53


🏁 Script executed:

# Look at the entire charge.refunded block to see if there's any other way amount_refunded or refund data could be obtained
sed -n '627,675p' frontend/app/api/shop/webhooks/stripe/route.ts

Repository: DevLoversTeam/devlovers.net

Length of output: 1994


Clarify handling when refunds list is unavailable in charge.refunded events.

The fallback logic at lines 640-664 correctly sums charge.refunds.data when amount_refunded is missing. However, the edge case the reviewer identified is valid: if charge.refunds.data is empty AND refund is null (because charge?.refunds?.data?.[0] returns undefined), then cumulativeRefunded becomes 0, not null. Since the error check at line 663 only throws when cumulativeRefunded == null, it proceeds with isFullRefund = (0 === amt), which will be false even if a full refund just occurred.

This can happen due to API timing: the charge.refunded webhook event fires before the charge object's refunds.data array is populated. The current logic treats this as "no refunds recorded" (cumulativeRefunded = 0) rather than "refund status undetermined."

Consider either:

  1. Throwing REFUND_FULLNESS_UNDETERMINED when the refunds list is empty and refund data cannot be extracted from the event, or
  2. Adding a check that validates whether currentAmt could actually be determined from the event before relying on the fallback calculation.


isFullRefund = cumulativeRefunded === amt;
} else if (eventType === 'charge.refund.updated' && refund) {
// Ensure we have the Charge to compute cumulative refunded correctly.
let effectiveCharge: Stripe.Charge | undefined;
Expand Down Expand Up @@ -735,7 +778,9 @@ export async function POST(request: NextRequest) {
and(eq(orders.id, order.id), ne(orders.paymentStatus, 'refunded'))
);

await restockOrder(order.id, { reason: 'refunded' });
if (shouldRestockFromWebhook(order)) {
await restockOrder(order.id, { reason: 'refunded' });
}

logWebhookEvent({
orderId: order.id,
Expand Down
Loading