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
3 changes: 3 additions & 0 deletions apps/api/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ import { BullModule } from '@nestjs/bullmq';
import { BullBoardModule } from '@bull-board/nestjs';
import { ExpressAdapter } from '@bull-board/express';

import { depthLimit } from './config/graphql-depth.util';

@Module({
imports: [
ConfigModule.forRoot({
Expand All @@ -56,6 +58,7 @@ import { ExpressAdapter } from '@bull-board/express';
sortSchema: true,
playground: process.env['NODE_ENV'] !== 'production',
introspection: process.env['NODE_ENV'] !== 'production',
validationRules: [depthLimit(10)],
subscriptions: {
'graphql-ws': true,
},
Expand Down
27 changes: 27 additions & 0 deletions apps/api/src/config/graphql-depth.util.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { GraphQLError, ValidationContext, ASTVisitor } from 'graphql';

export function depthLimit(maxDepth: number) {
return (context: ValidationContext): ASTVisitor => {
let depth = 0;
return {
Field: {
enter(node) {
depth++;
if (depth > maxDepth) {
context.reportError(
new GraphQLError(
`Query depth exceeds maximum limit of ${maxDepth}`,
{
nodes: [node],
},
),
);
}
},
leave() {
depth--;
},
},
};
};
}
Comment on lines +3 to +27

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

git ls-files apps/api/src/config/graphql-depth.util.ts

Repository: tahiry-dev-29/Swift-Shop

Length of output: 204


🏁 Script executed:

sed -n '1,220p' apps/api/src/config/graphql-depth.util.ts

Repository: tahiry-dev-29/Swift-Shop

Length of output: 787


🏁 Script executed:

rg -n "depthLimit\(" apps/api/src -S

Repository: tahiry-dev-29/Swift-Shop

Length of output: 324


🌐 Web query:

graphql-js validation visitor fragment definitions traversal fragment spreads depthLimit validation rule semantics

💡 Result:

In graphql-js, the validation process is managed by the validate function, which utilizes a specialized visitor to perform a depth-first traversal of the GraphQL document AST [1][2]. This traversal is orchestrated in parallel across all enabled validation rules [2]. Fragment Definitions and Traversal During validation, the engine does not automatically "inline" fragments into the AST [2]. Instead, it uses the ValidationContext to manage relationships between fragment definitions and fragment spreads [3]. The ValidationContext tracks selections and dependencies, allowing rules to inspect fragment spreads via methods like getFragmentSpreads(selectionSet) [3][4]. When a validation rule needs to analyze a fragment's contents, it typically retrieves the fragment definition by name from the document and inspects its associated selection set [3][4]. TypeInfo, a utility class, is commonly used alongside the visitor to track the schema types corresponding to the current AST nodes during this traversal [5]. DepthLimit Validation Rule There is no standard built-in DepthLimit validation rule in the core graphql-js specification-compliant ruleset [6]. While there is a MaxIntrospectionDepthRule for limiting introspection, standard query depth limiting is often implemented as a custom validation rule [7][6]. Developers frequently implement such rules by creating a visitor that tracks the current depth during traversal, incrementing it when entering a selection set and decrementing it upon leaving, then raising a GraphQLError if a threshold is exceeded [8][7]. Semantics of Validation Traversal The visitor pattern in graphql-js supports enter and leave hooks for specific AST node types [9]. Because the validation process uses visitInParallel, it ensures that all registered validation rules receive the same node during the document-wide traversal, maintaining high performance while allowing rules to maintain their own isolated state [2]. When dealing with fragment spreads, the validation engine maintains a clear separation between the operation's own selection set and the selection sets defined within fragments, relying on the context to resolve these references on-demand [3][4].

Citations:


🏁 Script executed:

node - <<'JS'
try {
  const graphql = require('graphql');
  console.log('graphql version:', graphql.version || graphql.graphqlVersion || 'unknown');
  const { parse, visit } = graphql;
  const doc = parse(`
    query Q {
      user { ...F }
    }
    fragment F on User {
      posts { comments { id } }
    }
  `);
  const events = [];
  let depth = 0;
  visit(doc, {
    Field: {
      enter(node) {
        depth++;
        events.push(['enter', node.name.value, depth]);
      },
      leave(node) {
        events.push(['leave', node.name.value, depth]);
        depth--;
      }
    },
    FragmentDefinition: {
      enter(node) {
        events.push(['frag-enter', node.name.value, depth]);
      },
      leave(node) {
        events.push(['frag-leave', node.name.value, depth]);
      }
    }
  });
  console.log(JSON.stringify(events, null, 2));
} catch (e) {
  console.log('ERR', e && e.message);
  process.exitCode = 0;
}
JS

Repository: tahiry-dev-29/Swift-Shop

Length of output: 239


🌐 Web query:

graphql-js visit document fragment definitions visited separately from fragment spreads order validation depth rule

💡 Result:

In graphql-js, the document traversal performed by the visit function is a depth-first search that treats all nodes in the AST as part of a single tree [1]. Fragment definitions and fragment spreads are visited as part of this standard document traversal; they are not inherently "separated" by the visitor logic into distinct phases or execution orders [2][1]. Regarding validation, the order in which specific rules are processed is defined by the specifiedRules array, which establishes a predictable sequence for error reporting [3]. While the visit function itself follows the AST structure (depth-first), individual validation rules, such as NoFragmentCyclesRule or KnownFragmentNamesRule, may implement their own logic to track fragment dependencies and depth [3]. Key points regarding your query: 1. Traversal Order: The visit function performs a depth-first traversal of the entire document AST [1]. There is no separate "fragment definitions" phase; they are visited when the walker encounters them in the tree [1]. 2. Validation Context: Validation rules interact with the document by registering visitors that execute during this traversal [4]. Some advanced features, such as experimental fragment arguments, require initial passes (e.g., using TypeInfo) to index fragment definitions before evaluating spreads to resolve local parameters correctly [5][6]. 3. Depth and Cycles: Rules like NoFragmentCyclesRule and depth-related validation must explicitly maintain state across the traversal to detect circular dependencies or exceed limits, as the standard visit function simply traverses the provided tree [3]. 4. Fragment Spreads: Recent updates in graphql-js have refined how fragment spreads are visited, particularly concerning deferred spreads, to avoid unnecessary re-visitation during traversal [7]. If you are building a custom validation rule that requires knowledge of fragment definitions before evaluating spreads, it is common practice to perform an initial pass over the document to collect and index fragment definitions, or to use TypeInfo to maintain state during the traversal [5][6].

Citations:


Named fragment spreads undercount depth.

This visitor only tracks Field nodes, so selections reached through ...Fragment are measured in isolation instead of at the spread site. A query can push extra nesting into named fragments and exceed maxDepth without tripping this rule. Handle fragment spreads recursively or use a depth-limit library that does.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/src/config/graphql-depth.util.ts` around lines 3 - 27, Update
depthLimit so named fragment spreads are traversed with the depth accumulated at
their spread site, rather than validating fragment fields independently. Extend
the visitor logic around Field and fragment-spread handling to recursively
account for nested fragment selections while preserving maxDepth reporting
through context.reportError.

3 changes: 3 additions & 0 deletions apps/api/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ async function bootstrap() {
const app = await NestFactory.create(AppModule, { bufferLogs: true });
app.useLogger(app.get(PinoLogger));

// Enable trust proxy for secure client IP detection via request.ip
app.getHttpAdapter().getInstance().set('trust proxy', true);

const globalPrefix = 'api';
app.setGlobalPrefix(globalPrefix, {
exclude: ['/'],
Expand Down
10 changes: 0 additions & 10 deletions libs/backend/core/auth/src/lib/guards/auth-rate-limit.guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,16 +70,6 @@ export class AuthRateLimitGuard implements CanActivate {
}

private getIpTracker(request: Request): string {
const forwardedFor = request.headers['x-forwarded-for'];

if (Array.isArray(forwardedFor)) {
return forwardedFor[0] ?? this.getRequestIp(request);
}

if (forwardedFor) {
return forwardedFor.split(',')[0]?.trim() ?? this.getRequestIp(request);
}

return this.getRequestIp(request);
}

Expand Down
54 changes: 38 additions & 16 deletions libs/backend/core/auth/src/lib/services/auth-credentials.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,13 @@ export class AuthCredentialsService {
include: { group: true },
});

if (!customer || !customer.active || this.isLocked(customer.lockedUntil)) {
if (
!customer ||
!customer.active ||
this.isLocked(customer.lockedUntil) ||
(customer.lockedUntil === null &&
customer.failedLoginAttempts >= LOCKOUT_THRESHOLD)
) {
return null;
}
Comment on lines +24 to 32

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.

critical

This condition introduces a permanent lockout bug. If customer.lockedUntil is null but customer.failedLoginAttempts is greater than or equal to LOCKOUT_THRESHOLD (which can happen if the threshold is lowered or due to a race condition/interrupted write), the user will be blocked from logging in. Because the password verification is bypassed entirely, the user can never successfully authenticate to trigger clearCustomerFailedLogins, and since lockedUntil is null, the lockout never expires. Reverting to the original check avoids this permanent lockout state.

    if (!customer || !customer.active || this.isLocked(customer.lockedUntil)) {
      return null;
    }


Expand All @@ -44,7 +50,13 @@ export class AuthCredentialsService {
include: { role: true },
});

if (!employee || !employee.active || this.isLocked(employee.lockedUntil)) {
if (
!employee ||
!employee.active ||
this.isLocked(employee.lockedUntil) ||
(employee.lockedUntil === null &&
employee.failedLoginAttempts >= LOCKOUT_THRESHOLD)
) {
return null;
}
Comment on lines +53 to 61

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.

critical

This condition introduces a permanent lockout bug for employees. If employee.lockedUntil is null but employee.failedLoginAttempts is greater than or equal to LOCKOUT_THRESHOLD, the employee will be permanently blocked from logging in with no way to unlock, as password verification is bypassed and the lockout has no expiration date. Reverting to the original check avoids this permanent lockout state.

    if (!employee || !employee.active || this.isLocked(employee.lockedUntil)) {
      return null;
    }


Expand Down Expand Up @@ -80,15 +92,20 @@ export class AuthCredentialsService {
});
if (customer.failedLoginAttempts >= LOCKOUT_THRESHOLD) {
const lockedUntil = this.lockoutDate();
await this.prisma.customer.update({
where: { id },
const updated = await this.prisma.customer.updateMany({
where: {
id,
OR: [{ lockedUntil: null }, { lockedUntil: { lt: new Date() } }],
},
data: { lockedUntil },
});
await this.sendAccountLockoutAlert(
customer.email,
'customer',
lockedUntil,
);
if (updated.count > 0) {
await this.sendAccountLockoutAlert(
customer.email,
'customer',
lockedUntil,
);
}
}
}

Expand All @@ -99,15 +116,20 @@ export class AuthCredentialsService {
});
if (employee.failedLoginAttempts >= LOCKOUT_THRESHOLD) {
const lockedUntil = this.lockoutDate();
await this.prisma.employee.update({
where: { id },
const updated = await this.prisma.employee.updateMany({
where: {
id,
OR: [{ lockedUntil: null }, { lockedUntil: { lt: new Date() } }],
},
data: { lockedUntil },
});
await this.sendAccountLockoutAlert(
employee.email,
'employee',
lockedUntil,
);
if (updated.count > 0) {
await this.sendAccountLockoutAlert(
employee.email,
'employee',
lockedUntil,
);
}
}
}

Expand Down
54 changes: 32 additions & 22 deletions libs/backend/core/auth/src/lib/services/auth-oauth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,30 +66,40 @@ export class AuthOAuthService {
throw new Error('Default customer group missing');
}

const customer = await this.prisma.customer.upsert({
where: { email: profile.email },
update: {},
create: {
email: profile.email,
password: await this.passwordSecurity.hashWithoutPolicy(
randomBytes(32).toString('base64url'),
),
firstname: profile.firstname,
lastname: profile.lastname,
groupId: group.id,
},
include: { group: true },
});
return this.prisma.$transaction(async (tx) => {
const customer = await tx.customer.upsert({
where: { email: profile.email },
update: {},
create: {
email: profile.email,
password: await this.passwordSecurity.hashWithoutPolicy(
randomBytes(32).toString('base64url'),
),
firstname: profile.firstname,
lastname: profile.lastname,
groupId: group.id,
},
include: { group: true },
});
Comment on lines +69 to +83

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.

high

Performing CPU-intensive cryptographic operations like password hashing (this.passwordSecurity.hashWithoutPolicy) inside an interactive database transaction holds the database connection and locks open much longer than necessary. This can easily lead to connection pool exhaustion and transaction timeouts under load. Generating the password hash before starting the transaction resolves this issue.

    const password = await this.passwordSecurity.hashWithoutPolicy(
      randomBytes(32).toString('base64url'),
    );

    return this.prisma.$transaction(async (tx) => {
      const customer = await tx.customer.upsert({
        where: { email: profile.email },
        update: {},
        create: {
          email: profile.email,
          password,
          firstname: profile.firstname,
          lastname: profile.lastname,
          groupId: group.id,
        },
        include: { group: true },
      });


await this.prisma.oAuthAccount.create({
data: {
provider: profile.provider,
providerAccountId: profile.providerAccountId,
email: profile.email,
customerId: customer.id,
},
await tx.oAuthAccount.upsert({
where: {
provider_providerAccountId: {
provider: profile.provider,
providerAccountId: profile.providerAccountId,
},
},
update: {},
create: {
provider: profile.provider,
providerAccountId: profile.providerAccountId,
email: profile.email,
customerId: customer.id,
},
});

return customer;
});
Comment on lines +69 to 102

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🚀 Performance & Scalability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

sed -n '1,220p' libs/backend/core/auth/src/lib/services/auth-oauth.service.ts

Repository: tahiry-dev-29/Swift-Shop

Length of output: 6311


🏁 Script executed:

sed -n '1,220p' libs/backend/core/auth/src/lib/services/password-security.service.ts

Repository: tahiry-dev-29/Swift-Shop

Length of output: 2668


Move the password hash out of the transaction. hashWithoutPolicy() is just argon2.hash(...), so doing it inside this.prisma.$transaction(async (tx) => ...) keeps the interactive transaction open while the CPU-bound hash runs. Compute the dummy password first, then enter the transaction.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@libs/backend/core/auth/src/lib/services/auth-oauth.service.ts` around lines
69 - 102, Move the password generation and passwordSecurity.hashWithoutPolicy
call out of the prisma.$transaction callback, compute the hashed dummy password
before starting the transaction, and use that value in the customer upsert’s
create data while preserving the existing transaction operations.

return customer;
}

private getOAuthConfig(provider: OAuthProvider) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,14 +81,14 @@ export class AuthRecoveryService {
}

async completeForcedPasswordReset(token: string, password: string) {
await this.passwordSecurity.assertPasswordPolicy(password);
const payload = this.jwtService.verify<JwtPayload>(token);
if (
payload.type !== 'employee' ||
payload.purpose !== 'employee_password_reset'
) {
return null;
}
await this.passwordSecurity.assertPasswordPolicy(password);
const hashedPassword =
await this.passwordSecurity.hashWithoutPolicy(password);
return this.prisma.employee.update({
Expand Down
12 changes: 12 additions & 0 deletions libs/backend/core/media/src/lib/media.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,18 @@ export class MediaService implements OnModuleInit {

try {
const image = sharp(file.buffer);
const metadata = await image.metadata();

if (metadata.width && metadata.width > 4096) {
throw new BadRequestException(
`Image width of ${metadata.width}px exceeds maximum allowed size of 4096px`,
);
}
if (metadata.height && metadata.height > 4096) {
throw new BadRequestException(
`Image height of ${metadata.height}px exceeds maximum allowed size of 4096px`,
);
}
Comment on lines +40 to +49

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Specific dimension error messages are swallowed by the catch block.

The BadRequestException thrown at lines 41-43 and 46-48 is caught by the generic catch at line 84, which replaces it with 'Could not process image'. The image is still rejected (400 response), but the user loses the actionable detail about which dimension exceeded the limit.

Re-throw HttpException subclasses so the original message survives:

🛡️ Proposed fix
     } catch (e) {
+      if (e instanceof HttpException) {
+        throw e;
+      }
       this.logger.error(`Image processing failed: ${String(e)}`);
       throw new BadRequestException('Could not process image');
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@libs/backend/core/media/src/lib/media.service.ts` around lines 40 - 49,
Update the image-processing catch block in the surrounding media service method
to rethrow existing HttpException instances, including the BadRequestException
dimension checks, before converting unexpected errors to the generic “Could not
process image” response. Preserve the original exception message and status
while retaining the current fallback handling for non-HttpException errors.


// Save original as WebP
const originalPath = path.join(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,24 +12,19 @@ export class ProductStockService {

async updateStock(input: UpdateStockInput) {
const { productId, combinationId, ...stockData } = input;
const existingStock = await this.prisma.stock.findFirst({
where: {
OR: [
{ productId: productId ?? undefined },
{ combinationId: combinationId ?? undefined },
],
},
});

if (existingStock) {
return this.prisma.stock.update({
where: { id: existingStock.id },
data: stockData,
});
if (!productId && !combinationId) {
throw new BadRequestException(
'Either productId or combinationId must be provided',
);
}

return this.prisma.stock.create({
data: {
const where = productId ? { productId } : { combinationId };

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.

high

When both productId and combinationId are provided, productId takes precedence in the current logic. However, combinationId represents a specific product variant/combination, which is more specific than the general productId. If both are provided, the upsert should target the specific combination's stock rather than the product-level stock. Otherwise, updating a variant's stock will incorrectly overwrite the product-level stock or another variant's stock.

Suggested change
const where = productId ? { productId } : { combinationId };
const where = combinationId ? { combinationId } : { productId };


return this.prisma.stock.upsert({
where,
update: stockData,
create: {
productId,
combinationId,
...stockData,
Expand Down
44 changes: 27 additions & 17 deletions libs/backend/features/order/src/lib/guest-checkout.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,30 +40,40 @@ export class GuestCheckoutService {
firstname: string,
lastname: string,
) {
const existingCustomer = await this.prisma.customer.findUnique({
where: { email },
});
if (existingCustomer) {
return existingCustomer;
}

const group = await this.prisma.customerGroup.findFirst({
orderBy: { name: 'asc' },
});
if (!group) {
throw new BadRequestException('Default customer group is missing');
}

return this.prisma.customer.create({
data: {
email,
firstname,
lastname,
password: `guest:${randomUUID()}`,
isGuest: true,
groupId: group.id,
},
});
try {
return await this.prisma.customer.create({
data: {
email,
firstname,
lastname,
password: `guest:${randomUUID()}`,
isGuest: true,
groupId: group.id,
},
});
} catch (e: unknown) {
if (
typeof e === 'object' &&
e !== null &&
'code' in e &&
(e as { code: string }).code === 'P2002'
) {
const existingCustomer = await this.prisma.customer.findUnique({
where: { email },
});
if (existingCustomer) {
return existingCustomer;
}
Comment on lines +68 to +73

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.

security-high high

Allowing guest checkout to silently proceed and merge carts with an existing registered customer account (where isGuest is false) is a security and privacy risk. An unauthenticated guest could view/merge items into a registered user's cart or place unauthorized orders under their account. If the email belongs to a registered customer, the system should throw an error prompting them to log in.

        const existingCustomer = await this.prisma.customer.findUnique({
          where: { email },
        });
        if (existingCustomer) {
          if (!existingCustomer.isGuest) {
            throw new BadRequestException(
              'This email is registered to an existing account. Please log in to proceed.',
            );
          }
          return existingCustomer;
        }

}
throw e;
}
Comment on lines +50 to +76

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Check whether the previous version of getOrCreateGuestCustomer checked isGuest,
# and whether downstream code guards against non-guest customers.

# Search for isGuest checks in the guest checkout flow
rg -n 'isGuest' libs/backend/features/order/src/lib/guest-checkout.service.ts

# Check if attachCartToCustomer or createOrderFromCart validate customer type
rg -n 'isGuest|guest' libs/backend/features/order/src/lib/order-creation.service.ts

Repository: tahiry-dev-29/Swift-Shop

Length of output: 410


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Map the relevant files first, then inspect the guest checkout flow and its callers.
ast-grep outline libs/backend/features/order/src/lib/guest-checkout.service.ts --view expanded
ast-grep outline libs/backend/features/order/src/lib/order-creation.service.ts --view expanded

echo '--- guest-checkout.service.ts ---'
cat -n libs/backend/features/order/src/lib/guest-checkout.service.ts | sed -n '1,220p'

echo '--- order-creation.service.ts ---'
cat -n libs/backend/features/order/src/lib/order-creation.service.ts | sed -n '1,260p'

Repository: tahiry-dev-29/Swift-Shop

Length of output: 11915


Guard against non-guest accounts here libs/backend/features/order/src/lib/guest-checkout.service.ts:68-73

If email already belongs to a registered customer, this returns their record and lets guest checkout attach the cart to that account. Only reuse the row when existingCustomer?.isGuest is true; otherwise raise a validation error.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@libs/backend/features/order/src/lib/guest-checkout.service.ts` around lines
50 - 76, Update the duplicate-email handling in the customer creation flow to
reuse existingCustomer only when existingCustomer.isGuest is true. If the
matching customer is registered, raise the service’s established validation
error instead of returning that record; preserve rethrowing unrelated errors.

}

private async attachCartToCustomer(cartId: string, customerId: string) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Injectable } from '@nestjs/common';
import { Injectable, BadRequestException } from '@nestjs/common';
import { PaymentAdapter } from './payment-adapter.interface';
import { LocalPaymentAdapter } from './local-payment-adapter';

Expand All @@ -13,11 +13,9 @@ export class PaymentAdapterRegistry {
get(provider: string): PaymentAdapter {
const adapter = this.adapters.get(provider.toLowerCase());
if (!adapter) {
const manual = this.adapters.get('manual');
if (!manual) {
throw new Error('Manual payment adapter not found in registry');
}
return manual;
throw new BadRequestException(
`Unsupported payment provider: ${provider}`,
);
}
return adapter;
}
Expand Down
9 changes: 6 additions & 3 deletions libs/backend/features/payment/src/lib/payment.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,13 @@ export class PaymentService {
throw new NotFoundException('Order not found');
}

const paymentAmount = amount ?? Number(order.totalTTC);
if (paymentAmount <= 0) {
throw new BadRequestException('Payment amount must be positive');
const expectedAmount = Number(order.totalTTC);
if (amount !== undefined && amount !== expectedAmount) {
throw new BadRequestException(
`Payment amount must match the order total of ${expectedAmount}`,
);
}
const paymentAmount = expectedAmount;

const payment = await this.prisma.payment.create({
data: {
Expand Down