This commit is contained in:
2026-08-28 17:31:02 +02:00
commit 2b30e8bd39
694 changed files with 49243 additions and 0 deletions
@@ -0,0 +1,27 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { OrderModule } from '../order/OrderModule';
import { PaymentModule } from '../payment/PaymentModule';
import { StorefrontCartModule } from '../storefrontCart/StorefrontCartModule';
import { StorefrontCoreModule } from '../storefrontCore/StorefrontCoreModule';
import { StorefrontCheckoutController } from './controllers/StorefrontCheckoutController';
import { CheckoutSessionDiscount } from './entities/CheckoutSessionDiscount';
import { CheckoutSessionLine } from './entities/CheckoutSessionLine';
import { CheckoutSession } from './entities/CheckoutSession';
import { CheckoutPaymentPollerService } from './services/CheckoutPaymentPollerService';
import { CheckoutSessionService } from './services/CheckoutSessionService';
import { StorefrontCheckoutViewService } from './services/StorefrontCheckoutViewService';
@Module({
imports: [
TypeOrmModule.forFeature([CheckoutSession, CheckoutSessionLine, CheckoutSessionDiscount]),
StorefrontCoreModule,
StorefrontCartModule,
PaymentModule,
OrderModule
],
controllers: [StorefrontCheckoutController],
providers: [CheckoutSessionService, StorefrontCheckoutViewService, CheckoutPaymentPollerService],
exports: [TypeOrmModule.forFeature([CheckoutSession])]
})
export class StorefrontCheckoutModule {}
@@ -0,0 +1,180 @@
import { Controller, Get, HttpStatus, Post, Req, Res, UseFilters, Body, BadRequestException } from '@nestjs/common';
import { Throttle } from '@nestjs/throttler';
import { ConfigService } from '@nestjs/config';
import type { Request, Response } from 'express';
import type { Config } from '../../../types/Config';
import { throttleProfiles } from '../../../config/throttleProfiles';
import { deriveCheckoutSessionState } from '../../../utils/checkout/deriveCheckoutSessionState';
import { OrderService } from '../../order/services/OrderService';
import { StorefrontExceptionFilter } from '../../storefrontCore/filters/StorefrontExceptionFilter';
import { StorefrontCartCookieService } from '../../storefrontCore/services/StorefrontCartCookieService';
import { StorefrontCheckoutSessionCookieService } from '../../storefrontCore/services/StorefrontCheckoutSessionCookieService';
import { StorefrontDiscountCookieService } from '../../storefrontCore/services/StorefrontDiscountCookieService';
import { StorefrontFeedbackCookieService } from '../../storefrontCore/services/StorefrontFeedbackCookieService';
import { StorefrontCaptchaCookieService } from '../../storefrontCore/services/StorefrontCaptchaCookieService';
import { StorefrontCaptchaService } from '../../storefrontCore/services/StorefrontCaptchaService';
import { StorefrontOrderAuthCookieService } from '../../storefrontCore/services/StorefrontOrderAuthCookieService';
import { StorefrontShopViewService } from '../../storefrontCore/services/StorefrontShopViewService';
import { StorefrontCartService } from '../../storefrontCart/services/StorefrontCartService';
import { CheckoutSessionService } from '../services/CheckoutSessionService';
import { PayCheckoutDto } from '../dto/PayCheckoutDto';
import { StorefrontCheckoutViewService } from '../services/StorefrontCheckoutViewService';
@Controller()
@UseFilters(StorefrontExceptionFilter)
export class StorefrontCheckoutController {
constructor(
private readonly cartService: StorefrontCartService,
private readonly checkoutSessionService: CheckoutSessionService,
private readonly checkoutViewService: StorefrontCheckoutViewService,
private readonly shopViewService: StorefrontShopViewService,
private readonly cartCookieService: StorefrontCartCookieService,
private readonly discountCookieService: StorefrontDiscountCookieService,
private readonly checkoutSessionCookieService: StorefrontCheckoutSessionCookieService,
private readonly feedbackCookieService: StorefrontFeedbackCookieService,
private readonly orderAuthCookieService: StorefrontOrderAuthCookieService,
private readonly orderService: OrderService,
private readonly configService: ConfigService,
private readonly captchaService: StorefrontCaptchaService,
private readonly captchaCookieService: StorefrontCaptchaCookieService
) {}
@Post('shop/checkout/pay')
@Throttle(throttleProfiles.checkoutPay)
async pay(@Req() req: Request, @Res() res: Response, @Body() { captcha }: PayCheckoutDto): Promise<void> {
const sessionId = this.checkoutSessionCookieService.getSessionId(req, res);
if (sessionId) {
res.redirect(HttpStatus.FOUND, '/shop/checkout');
return;
}
const encryptedAnswer = this.captchaCookieService.getAnswer(req, res, { consume: true });
const isCaptchaValid = this.captchaService.verify(captcha, encryptedAnswer);
if (!isCaptchaValid) {
throw new BadRequestException('Incorrect captcha. Try again.');
}
const cart = this.cartCookieService.getCart(req, res);
const discountCodes = this.discountCookieService.getDiscountCodes(req, res);
const summary = await this.cartService.getCartSummary(cart, discountCodes);
const session = await this.checkoutSessionService.createFromCartSummary(summary);
this.checkoutSessionCookieService.setSessionId(req, res, session.id);
res.redirect(HttpStatus.FOUND, '/shop/checkout');
}
@Get('shop/checkout')
async checkoutPage(@Req() req: Request, @Res() res: Response) {
const sessionId = this.checkoutSessionCookieService.getSessionId(req, res);
if (!sessionId) {
res.redirect(HttpStatus.FOUND, '/shop/cart');
return;
}
const session = await this.checkoutSessionService.findById(sessionId);
if (!session) {
this.checkoutSessionCookieService.clearSession(req, res);
this.feedbackCookieService.setFeedback(req, res, {
type: 'error',
text: 'Checkout session no longer available.'
});
res.redirect(HttpStatus.FOUND, '/shop/cart');
return;
}
const orderId = await this.orderService.findIdByCheckoutSessionId(sessionId);
if (orderId) {
this.checkoutSessionCookieService.clearSession(req, res);
this.cartCookieService.clearCart(req, res);
this.discountCookieService.clearDiscount(req, res);
this.orderAuthCookieService.grantAccess(req, res, orderId);
this.feedbackCookieService.setFeedback(req, res, {
type: 'success',
text: 'Order placed successfully.'
});
res.redirect(HttpStatus.FOUND, `/shop/order/${orderId}`);
return;
}
const sessionState = deriveCheckoutSessionState(session);
if (sessionState.isPastDue) {
this.checkoutSessionCookieService.clearSession(req, res);
this.feedbackCookieService.setFeedback(req, res, {
type: 'error',
text: 'Checkout session expired.'
});
res.redirect(HttpStatus.FOUND, '/shop/cart');
return;
}
if (sessionState.isCancelled) {
this.checkoutSessionCookieService.clearSession(req, res);
this.feedbackCookieService.setFeedback(req, res, {
type: 'success',
text: 'Checkout cancelled.'
});
res.redirect(HttpStatus.FOUND, '/shop/cart');
return;
}
const [shopLocals, checkoutView] = await Promise.all([
this.shopViewService.buildShopRenderLocals(req, res, {
title: 'Checkout',
metaDescription: 'Complete your purchase at {shopName}.'
}),
this.checkoutViewService.toCheckoutView(session)
]);
const { checkoutStatusRefreshSec } = this.configService.get('order') as Config['order'];
return res.render('checkout', {
checkout: checkoutView,
refreshSec: checkoutStatusRefreshSec,
...shopLocals
});
}
@Post('shop/checkout/cancel')
async cancelCheckout(@Req() req: Request, @Res() res: Response): Promise<void> {
const sessionId = this.checkoutSessionCookieService.getSessionId(req, res);
if (sessionId) {
await this.checkoutSessionService.cancelSession(sessionId);
}
this.checkoutSessionCookieService.clearSession(req, res);
this.feedbackCookieService.setFeedback(req, res, {
type: 'success',
text: 'Checkout cancelled.'
});
res.redirect(HttpStatus.FOUND, '/shop/cart');
}
}
@@ -0,0 +1,15 @@
import { IsNotEmpty, IsString, Length } from 'class-validator';
import { getAppConfig } from '../../../config';
const {
captcha: { length: captchaLength }
} = getAppConfig();
export class PayCheckoutDto {
@IsNotEmpty()
@IsString()
@Length(captchaLength, captchaLength, {
message: `Captcha should be ${captchaLength} characters long`
})
captcha: string;
}
@@ -0,0 +1,42 @@
import {
Column,
CreateDateColumn,
Entity,
JoinColumn,
OneToMany,
OneToOne,
PrimaryGeneratedColumn,
UpdateDateColumn
} from 'typeorm';
import { Invoice } from '../../payment/entities/Invoice';
import { Order } from '../../order/entities/Order';
import { CheckoutSessionDiscount } from './CheckoutSessionDiscount';
import { CheckoutSessionLine } from './CheckoutSessionLine';
@Entity('checkout_sessions')
export class CheckoutSession {
@PrimaryGeneratedColumn('uuid')
id: string;
@OneToOne(() => Invoice, { cascade: true })
@JoinColumn()
invoice: Invoice;
@OneToOne(() => Order, order => order.checkoutSession)
order: Order | null;
@OneToMany(() => CheckoutSessionLine, line => line.session, { cascade: true })
lines: CheckoutSessionLine[];
@OneToMany(() => CheckoutSessionDiscount, discount => discount.session, { cascade: true })
discounts: CheckoutSessionDiscount[];
@Column({ type: 'timestamptz', nullable: true })
cancelledAt: Date | null;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}
@@ -0,0 +1,24 @@
import { Column, Entity, JoinColumn, ManyToOne, PrimaryGeneratedColumn } from 'typeorm';
import { ColumnNumericTransformer } from '../../../utils/ColumnNumericTransformer';
import { CheckoutSession } from './CheckoutSession';
@Entity('checkout_session_discounts')
export class CheckoutSessionDiscount {
@PrimaryGeneratedColumn('uuid')
id: string;
@ManyToOne(() => CheckoutSession, session => session.discounts, { onDelete: 'CASCADE' })
@JoinColumn()
session: CheckoutSession;
@Column({ length: 32 })
code: string;
@Column({
type: 'numeric',
precision: 12,
scale: 2,
transformer: new ColumnNumericTransformer()
})
amountFiat: number;
}
@@ -0,0 +1,51 @@
import { Column, Entity, JoinColumn, ManyToOne, PrimaryGeneratedColumn } from 'typeorm';
import { ColumnNumericTransformer } from '../../../utils/ColumnNumericTransformer';
import { DeliveryMode } from '../../product/types/DeliveryMode';
import { CheckoutSession } from './CheckoutSession';
@Entity('checkout_session_lines')
export class CheckoutSessionLine {
@PrimaryGeneratedColumn('uuid')
id: string;
@ManyToOne(() => CheckoutSession, session => session.lines, { onDelete: 'CASCADE' })
@JoinColumn()
session: CheckoutSession;
@Column({ type: 'uuid' })
variantId: string;
@Column({ type: 'uuid' })
productId: string;
@Column()
productTitle: string;
@Column()
variantTitle: string;
@Column({ type: 'varchar', nullable: true })
thumbnailUrl: string | null;
@Column({ type: 'int' })
qty: number;
@Column({
type: 'numeric',
precision: 12,
scale: 2,
transformer: new ColumnNumericTransformer()
})
unitPriceFiat: number;
@Column({
type: 'numeric',
precision: 12,
scale: 2,
transformer: new ColumnNumericTransformer()
})
lineSubtotalFiat: number;
@Column({ type: 'enum', enum: DeliveryMode })
deliveryMode: DeliveryMode;
}
@@ -0,0 +1,145 @@
import { Logger } from '@nestjs/common';
import type { Repository } from 'typeorm';
import type { Invoice } from '../../payment/entities/Invoice';
import { PaymentMethod } from '../../payment/types/PaymentMethod';
import type { OrderCreationService } from '../../order/services/OrderCreationService';
import { CheckoutSession } from '../entities/CheckoutSession';
import type { CheckoutPaymentPollerServiceTest } from '../types/CheckoutPaymentPollerServiceTest';
import { CheckoutPaymentPollerService } from './CheckoutPaymentPollerService';
const buildPaidInvoice = () => ({
paymentMethod: PaymentMethod.Xmr,
expectedTotalAtomic: '1000',
expiresAt: new Date('2099-01-01T00:00:00.000Z'),
moneroDetails: { requiredConfirmations: 1 },
payments: [{ amountAtomic: '1000', confirmations: 1 }]
});
const buildSession = (overrides: Partial<CheckoutSession> = {}): CheckoutSession =>
({
id: 'session-1',
invoice: buildPaidInvoice(),
...overrides
}) as CheckoutSession;
describe('CheckoutPaymentPollerService', () => {
let service: CheckoutPaymentPollerServiceTest;
let sessionRepo: {
createQueryBuilder: jest.Mock;
};
let sessionQueryBuilder: {
innerJoinAndSelect: jest.Mock;
leftJoinAndSelect: jest.Mock;
leftJoin: jest.Mock;
where: jest.Mock;
andWhere: jest.Mock;
getMany: jest.Mock;
};
let orderCreationService: {
createFromPaidSession: jest.Mock;
};
let errorLogSpy: jest.SpiedFunction<typeof Logger.prototype.error>;
beforeEach(() => {
errorLogSpy = jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
sessionQueryBuilder = {
innerJoinAndSelect: jest.fn().mockReturnThis(),
leftJoinAndSelect: jest.fn().mockReturnThis(),
leftJoin: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
getMany: jest.fn().mockResolvedValue([])
};
sessionRepo = {
createQueryBuilder: jest.fn().mockReturnValue(sessionQueryBuilder)
};
orderCreationService = {
createFromPaidSession: jest.fn().mockResolvedValue(undefined)
};
service = new CheckoutPaymentPollerService(
sessionRepo as unknown as Repository<CheckoutSession>,
orderCreationService as unknown as OrderCreationService
) as unknown as CheckoutPaymentPollerServiceTest;
});
afterEach(() => {
errorLogSpy.mockRestore();
});
it('does nothing when there are no open checkout sessions', async () => {
sessionQueryBuilder.getMany.mockResolvedValue([]);
await service.pollCheckoutSessions();
expect(orderCreationService.createFromPaidSession).not.toHaveBeenCalled();
});
it('skips sessions without an invoice', async () => {
sessionQueryBuilder.getMany.mockResolvedValue([buildSession({ invoice: undefined })]);
await service.pollCheckoutSessions();
expect(orderCreationService.createFromPaidSession).not.toHaveBeenCalled();
});
it('skips sessions whose invoice is not paid sufficiently', async () => {
sessionQueryBuilder.getMany.mockResolvedValue([
buildSession({
invoice: {
...buildPaidInvoice(),
payments: [{ amountAtomic: '100', confirmations: 1 }]
} as Invoice
})
]);
await service.pollCheckoutSessions();
expect(orderCreationService.createFromPaidSession).not.toHaveBeenCalled();
});
it('creates an order for paid checkout sessions', async () => {
sessionQueryBuilder.getMany.mockResolvedValue([buildSession({ id: 'session-paid' })]);
await service.pollCheckoutSessions();
expect(orderCreationService.createFromPaidSession).toHaveBeenCalledWith('session-paid');
});
it('creates orders only for paid sessions when the poll batch is mixed', async () => {
sessionQueryBuilder.getMany.mockResolvedValue([
buildSession({
id: 'session-unpaid',
invoice: {
...buildPaidInvoice(),
payments: [{ amountAtomic: '100', confirmations: 1 }]
} as Invoice
}),
buildSession({ id: 'session-paid' })
]);
await service.pollCheckoutSessions();
expect(orderCreationService.createFromPaidSession).toHaveBeenCalledTimes(1);
expect(orderCreationService.createFromPaidSession).toHaveBeenCalledWith('session-paid');
});
it('continues processing other sessions when order creation fails for one session', async () => {
sessionQueryBuilder.getMany.mockResolvedValue([
buildSession({ id: 'session-fail' }),
buildSession({ id: 'session-ok' })
]);
orderCreationService.createFromPaidSession
.mockRejectedValueOnce(new Error('create failed'))
.mockResolvedValueOnce(undefined);
await service.pollCheckoutSessions();
expect(orderCreationService.createFromPaidSession).toHaveBeenNthCalledWith(1, 'session-fail');
expect(orderCreationService.createFromPaidSession).toHaveBeenNthCalledWith(2, 'session-ok');
});
});
@@ -0,0 +1,57 @@
import { Injectable, Logger } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { getErrorMessage } from '../../../utils/getErrorMessage';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { deriveInvoiceState } from '../../../utils/invoice/deriveInvoiceState';
import { OrderCreationService } from '../../order/services/OrderCreationService';
import { CheckoutSession } from '../entities/CheckoutSession';
@Injectable()
export class CheckoutPaymentPollerService {
private readonly logger = new Logger(CheckoutPaymentPollerService.name);
constructor(
@InjectRepository(CheckoutSession)
private readonly sessionRepo: Repository<CheckoutSession>,
private readonly orderCreationService: OrderCreationService
) {}
@Cron(CronExpression.EVERY_10_SECONDS)
private async pollCheckoutSessions(): Promise<void> {
const now = new Date();
const sessions = await this.sessionRepo
.createQueryBuilder('session')
.innerJoinAndSelect('session.invoice', 'invoice')
.leftJoinAndSelect('invoice.moneroDetails', 'moneroDetails')
.leftJoinAndSelect('invoice.payments', 'payment')
.leftJoin('session.order', 'order')
.where('session.cancelledAt IS NULL')
.andWhere('order.id IS NULL')
.andWhere('invoice.expiresAt > :now', { now })
.getMany();
for (const session of sessions) {
const invoice = session.invoice;
if (!invoice) {
continue;
}
const { isPaidSufficient } = deriveInvoiceState(invoice);
if (!isPaidSufficient) {
continue;
}
try {
await this.orderCreationService.createFromPaidSession(session.id);
} catch (error) {
this.logger.error(
`Failed to create order for paid checkout session ${session.id}: ${getErrorMessage(error)}`
);
}
}
}
}
@@ -0,0 +1,287 @@
import { BadRequestException } from '@nestjs/common';
import type { Repository } from 'typeorm';
import { DeliveryMode } from '../../product/types/DeliveryMode';
import type { Invoice } from '../../payment/entities/Invoice';
import { InvoiceReason } from '../../payment/types/InvoiceReason';
import { PaymentMethod } from '../../payment/types/PaymentMethod';
import type { InvoiceService } from '../../payment/services/InvoiceService';
import type { CookieCartSummary } from '../../storefrontCart/types/CookieCartSummary';
import { CheckoutSession } from '../entities/CheckoutSession';
import { CheckoutSessionDiscount } from '../entities/CheckoutSessionDiscount';
import { CheckoutSessionLine } from '../entities/CheckoutSessionLine';
import { CheckoutSessionService } from './CheckoutSessionService';
jest.mock('node:crypto', () => ({
randomUUID: () => 'session-uuid'
}));
const buildSummary = (overrides: Partial<CookieCartSummary> = {}): CookieCartSummary =>
({
cartExtended: [
{
id: 'variant-1',
productId: 'product-1',
productTitle: 'Product',
title: 'Variant',
price: 10,
deliveryMode: DeliveryMode.Auto,
stockAvailable: 5,
stockForSession: 5,
thumbnailUrl: null,
images: [],
qty: 1,
lineSubtotal: 10,
stockIssueMessage: null
}
],
cartSubtotal: 10,
discounts: [{ code: 'SAVE1', amount: 1, issueMessage: null }],
cartDiscountTotal: 1,
cartTotalPrice: 9,
cartTotalXmr: '0.06000000',
fiatPerXmr: 150,
hasManualLines: false,
hasAutoLines: true,
cartTotalIssueMessage: null,
hasIssues: false,
...overrides
}) as CookieCartSummary;
const buildInvoice = (overrides: Partial<Invoice> = {}): Invoice =>
({
id: 'invoice-1',
reason: InvoiceReason.Checkout,
paymentMethod: PaymentMethod.Xmr,
amountFiat: 9,
fiatCurrency: 'USD',
paymentAddress: '4CheckoutMoneroPaymentAddressExample',
expectedTotalAtomic: '60000000000',
expiresAt: new Date('2099-01-01T00:00:00.000Z'),
payments: [],
moneroDetails: {
paymentAddressIndex: 1,
fiatPerXmrAtCreation: 150,
requiredConfirmations: 1
},
createdAt: new Date('2025-01-01T00:00:00.000Z'),
...overrides
}) as Invoice;
describe('CheckoutSessionService', () => {
let service: CheckoutSessionService;
let sessionRepo: {
create: jest.Mock;
save: jest.Mock;
update: jest.Mock;
findOne: jest.Mock;
};
let lineRepo: {
create: jest.Mock;
};
let discountRepo: {
create: jest.Mock;
};
let invoiceService: {
issueInvoice: jest.Mock;
};
beforeEach(() => {
sessionRepo = {
create: jest.fn(data => data),
save: jest.fn(async (session: CheckoutSession) => session),
update: jest.fn().mockResolvedValue(undefined),
findOne: jest.fn().mockResolvedValue(null)
};
lineRepo = {
create: jest.fn(data => data)
};
discountRepo = {
create: jest.fn(data => data)
};
invoiceService = {
issueInvoice: jest.fn().mockResolvedValue(buildInvoice())
};
service = new CheckoutSessionService(
sessionRepo as unknown as Repository<CheckoutSession>,
lineRepo as unknown as Repository<CheckoutSessionLine>,
discountRepo as unknown as Repository<CheckoutSessionDiscount>,
invoiceService as unknown as InvoiceService
);
});
describe('createFromCartSummary', () => {
it('rejects an empty cart', async () => {
await expect(service.createFromCartSummary(buildSummary({ cartExtended: [] }))).rejects.toThrow(
new BadRequestException('Your cart is empty')
);
});
it('rejects carts that still have unresolved issues', async () => {
await expect(service.createFromCartSummary(buildSummary({ hasIssues: true }))).rejects.toThrow(
new BadRequestException('Resolve cart issues before paying')
);
});
it('creates a session, invoice, lines, and valid discounts from the cart summary', async () => {
const summary = buildSummary({
discounts: [
{ code: 'SAVE1', amount: 1, issueMessage: null },
{ code: 'BAD', amount: null, issueMessage: 'Invalid discount code' },
{ code: 'ZERO', amount: 0, issueMessage: null }
]
});
const session = await service.createFromCartSummary(summary);
expect(invoiceService.issueInvoice).toHaveBeenCalledWith({
paymentMethod: PaymentMethod.Xmr,
reason: InvoiceReason.Checkout,
contextId: 'session-uuid',
amountFiat: 9
});
expect(lineRepo.create).toHaveBeenCalledWith(
expect.objectContaining({
variantId: 'variant-1',
productId: 'product-1',
productTitle: 'Product',
variantTitle: 'Variant',
qty: 1,
unitPriceFiat: 10,
lineSubtotalFiat: 10,
deliveryMode: DeliveryMode.Auto
})
);
expect(discountRepo.create).toHaveBeenCalledTimes(1);
expect(discountRepo.create).toHaveBeenCalledWith({
code: 'SAVE1',
amountFiat: 1
});
expect(sessionRepo.create).toHaveBeenCalledWith(
expect.objectContaining({
id: 'session-uuid',
invoice: buildInvoice(),
lines: [expect.objectContaining({ variantId: 'variant-1' })],
discounts: [{ code: 'SAVE1', amountFiat: 1 }]
})
);
expect(sessionRepo.save).toHaveBeenCalled();
expect(session).toEqual(
expect.objectContaining({
id: 'session-uuid'
})
);
});
it('creates a session without discounts when none apply', async () => {
await service.createFromCartSummary(
buildSummary({
discounts: [{ code: 'BAD', amount: null, issueMessage: 'Invalid discount code' }]
})
);
expect(discountRepo.create).not.toHaveBeenCalled();
expect(sessionRepo.create).toHaveBeenCalledWith(
expect.objectContaining({
discounts: []
})
);
});
});
describe('findById', () => {
it('loads a session with checkout relations', async () => {
const session = { id: 'session-1' } as CheckoutSession;
sessionRepo.findOne.mockResolvedValue(session);
await expect(service.findById('session-1')).resolves.toBe(session);
expect(sessionRepo.findOne).toHaveBeenCalledWith({
where: { id: 'session-1' },
relations: ['lines', 'discounts', 'invoice', 'invoice.moneroDetails', 'invoice.payments']
});
});
});
describe('cancelSession', () => {
it('does nothing when the session does not exist', async () => {
sessionRepo.findOne.mockResolvedValue(null);
await service.cancelSession('missing-session');
expect(sessionRepo.update).not.toHaveBeenCalled();
});
it('does not cancel sessions that are already cancelled', async () => {
sessionRepo.findOne.mockResolvedValue({
id: 'session-1',
cancelledAt: new Date('2020-01-01T00:00:00.000Z'),
invoice: buildInvoice()
});
await service.cancelSession('session-1');
expect(sessionRepo.update).not.toHaveBeenCalled();
});
it('does not cancel sessions whose invoice is already paid', async () => {
sessionRepo.findOne.mockResolvedValue({
id: 'session-1',
cancelledAt: null,
invoice: {
...buildInvoice(),
expectedTotalAtomic: '60000000000',
payments: [{ amountAtomic: '60000000000', confirmations: 1 }]
}
});
await service.cancelSession('session-1');
expect(sessionRepo.update).not.toHaveBeenCalled();
});
it('does not cancel sessions without an invoice', async () => {
sessionRepo.findOne.mockResolvedValue({
id: 'session-1',
cancelledAt: null,
invoice: undefined
});
await service.cancelSession('session-1');
expect(sessionRepo.update).not.toHaveBeenCalled();
});
it('cancels expired but unpaid sessions that are still open for payment', async () => {
sessionRepo.findOne.mockResolvedValue({
id: 'session-1',
cancelledAt: null,
invoice: buildInvoice({
expiresAt: new Date('2020-01-01T00:00:00.000Z')
})
});
await service.cancelSession('session-1');
expect(sessionRepo.update).toHaveBeenCalledWith('session-1', {
cancelledAt: expect.any(Date)
});
});
it('cancels open unpaid sessions', async () => {
sessionRepo.findOne.mockResolvedValue({
id: 'session-1',
cancelledAt: null,
invoice: buildInvoice()
});
await service.cancelSession('session-1');
expect(sessionRepo.update).toHaveBeenCalledWith('session-1', {
cancelledAt: expect.any(Date)
});
});
});
});
@@ -0,0 +1,102 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { randomUUID } from 'node:crypto';
import { Repository } from 'typeorm';
import type { CookieCartSummary } from '../../storefrontCart/types/CookieCartSummary';
import { deriveCheckoutSessionState } from '../../../utils/checkout/deriveCheckoutSessionState';
import { InvoiceReason } from '../../payment/types/InvoiceReason';
import { InvoiceService } from '../../payment/services/InvoiceService';
import { PaymentMethod } from '../../payment/types/PaymentMethod';
import { CheckoutSessionDiscount } from '../entities/CheckoutSessionDiscount';
import { CheckoutSessionLine } from '../entities/CheckoutSessionLine';
import { CheckoutSession } from '../entities/CheckoutSession';
@Injectable()
export class CheckoutSessionService {
constructor(
@InjectRepository(CheckoutSession)
private readonly sessionRepo: Repository<CheckoutSession>,
@InjectRepository(CheckoutSessionLine)
private readonly lineRepo: Repository<CheckoutSessionLine>,
@InjectRepository(CheckoutSessionDiscount)
private readonly discountRepo: Repository<CheckoutSessionDiscount>,
private readonly invoiceService: InvoiceService
) {}
async findById(id: string): Promise<CheckoutSession | null> {
return this.sessionRepo.findOne({
where: { id },
relations: ['lines', 'discounts', 'invoice', 'invoice.moneroDetails', 'invoice.payments']
});
}
async createFromCartSummary(summary: CookieCartSummary): Promise<CheckoutSession> {
if (summary.cartExtended.length === 0) {
throw new BadRequestException('Your cart is empty');
}
if (summary.hasIssues) {
throw new BadRequestException('Resolve cart issues before paying');
}
const sessionId = randomUUID();
const invoice = await this.invoiceService.issueInvoice({
paymentMethod: PaymentMethod.Xmr,
reason: InvoiceReason.Checkout,
contextId: sessionId,
amountFiat: summary.cartTotalPrice
});
const lines = summary.cartExtended.map(line =>
this.lineRepo.create({
variantId: line.id,
productId: line.productId,
productTitle: line.productTitle,
variantTitle: line.title,
thumbnailUrl: line.thumbnailUrl,
qty: line.qty,
unitPriceFiat: line.price,
lineSubtotalFiat: line.lineSubtotal,
deliveryMode: line.deliveryMode
})
);
const discounts = summary.discounts
.filter(d => d.amount !== null && d.amount > 0 && !d.issueMessage)
.map(d =>
this.discountRepo.create({
code: d.code,
amountFiat: d.amount!
})
);
const session = this.sessionRepo.create({
id: sessionId,
invoice,
lines,
discounts
});
return this.sessionRepo.save(session);
}
async cancelSession(sessionId: string): Promise<void> {
const session = await this.sessionRepo.findOne({
where: { id: sessionId },
relations: ['invoice', 'invoice.moneroDetails', 'invoice.payments']
});
if (!session) {
return;
}
const { isOpenForPayment } = deriveCheckoutSessionState(session);
if (!isOpenForPayment) {
return;
}
await this.sessionRepo.update(session.id, { cancelledAt: new Date() });
}
}
@@ -0,0 +1,131 @@
import { InternalServerErrorException } from '@nestjs/common';
import { XMR_ATOMIC_PER_XMR } from '../../../consts/xmrAtomicPerXmr';
import * as toStorefrontInvoiceViewModule from '../../../utils/invoice/toStorefrontInvoiceView';
import type { StorefrontInvoiceView } from '../../storefrontCore/types/StorefrontInvoiceView';
import type { CheckoutSession } from '../entities/CheckoutSession';
import type { Invoice } from '../../payment/entities/Invoice';
import { PaymentMethod } from '../../payment/types/PaymentMethod';
import { InvoiceReason } from '../../payment/types/InvoiceReason';
import { DeliveryMode } from '../../product/types/DeliveryMode';
import { StorefrontCheckoutViewService } from './StorefrontCheckoutViewService';
const oneXmrAtomic = XMR_ATOMIC_PER_XMR.toString();
const buildInvoice = (overrides: Partial<Invoice> = {}): Invoice =>
({
reason: InvoiceReason.Checkout,
paymentMethod: PaymentMethod.Xmr,
amountFiat: 9,
fiatCurrency: 'USD',
expiresAt: new Date('2099-01-01T00:00:00.000Z'),
paymentAddress: '4CheckoutMoneroPaymentAddressExample',
expectedTotalAtomic: oneXmrAtomic,
payments: [],
moneroDetails: {
paymentAddressIndex: 1,
fiatPerXmrAtCreation: 150,
requiredConfirmations: 1
},
...overrides
}) as Invoice;
const buildSession = (overrides: Partial<CheckoutSession> = {}): CheckoutSession =>
({
id: 'session-1',
cancelledAt: null,
lines: [
{
productId: 'product-1',
variantId: 'variant-1',
productTitle: 'Product',
variantTitle: 'Variant',
thumbnailUrl: null,
qty: 2,
unitPriceFiat: 5,
lineSubtotalFiat: 10,
deliveryMode: DeliveryMode.Auto
}
],
discounts: [{ code: 'SAVE1', amountFiat: 1 }],
invoice: buildInvoice(),
...overrides
}) as CheckoutSession;
describe('StorefrontCheckoutViewService', () => {
let service: StorefrontCheckoutViewService;
let toStorefrontInvoiceViewSpy: jest.SpiedFunction<typeof toStorefrontInvoiceViewModule.toStorefrontInvoiceView>;
const checkoutInvoiceView = {
cryptoCurrency: 'XMR',
amountFiat: 9
} as unknown as StorefrontInvoiceView;
beforeEach(() => {
service = new StorefrontCheckoutViewService();
toStorefrontInvoiceViewSpy = jest
.spyOn(toStorefrontInvoiceViewModule, 'toStorefrontInvoiceView')
.mockResolvedValue(checkoutInvoiceView);
});
afterEach(() => {
toStorefrontInvoiceViewSpy.mockRestore();
});
it('maps checkout totals, lines, discounts, and invoice', async () => {
const session = buildSession();
const view = await service.toCheckoutView(session);
expect(view.totals).toEqual({
subtotalFiat: 10,
discountTotalFiat: 1,
totalFiat: 9
});
expect(view.lines).toEqual([
{
linkHref: '/shop/products/product-1/variants/variant-1',
thumbnailUrl: null,
productTitle: 'Product',
variantTitle: 'Variant',
qty: 2,
unitPriceFiat: 5,
lineSubtotalFiat: 10,
deliveryMode: DeliveryMode.Auto
}
]);
expect(view.discounts).toEqual([{ code: 'SAVE1', amountFiat: 1 }]);
expect(view.checkoutInvoice).toBe(checkoutInvoiceView);
expect(view.refreshHref).toBe('/shop/checkout');
expect(view.cancelCheckoutAction).toBe('/shop/checkout/cancel');
});
it('treats missing lines and discounts as empty collections', async () => {
const session = buildSession({ lines: [], discounts: [] });
const view = await service.toCheckoutView(session);
expect(view.totals).toEqual({
subtotalFiat: 0,
discountTotalFiat: 0,
totalFiat: 9
});
expect(view.lines).toEqual([]);
expect(view.discounts).toEqual([]);
});
it('delegates invoice mapping to toStorefrontInvoiceView', async () => {
const invoice = buildInvoice();
const session = buildSession({ invoice });
await service.toCheckoutView(session);
expect(toStorefrontInvoiceViewSpy).toHaveBeenCalledWith(invoice);
});
it('throws when the checkout session has no payment invoice', async () => {
const session = buildSession({ invoice: undefined });
await expect(service.toCheckoutView(session)).rejects.toBeInstanceOf(InternalServerErrorException);
expect(toStorefrontInvoiceViewSpy).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,55 @@
import { Injectable, InternalServerErrorException } from '@nestjs/common';
import { deriveCheckoutTotals } from '../../../utils/checkout/deriveCheckoutTotals';
import { toStorefrontDiscountView } from '../../../utils/storefront/toStorefrontDiscountView';
import { toStorefrontInvoiceView } from '../../../utils/invoice/toStorefrontInvoiceView';
import type { CheckoutSession } from '../entities/CheckoutSession';
import type { CheckoutSessionLine } from '../entities/CheckoutSessionLine';
import type { StorefrontCheckoutLineView } from '../types/StorefrontCheckoutLineView';
import type { StorefrontCheckoutView } from '../types/StorefrontCheckoutView';
@Injectable()
export class StorefrontCheckoutViewService {
async toCheckoutView(session: CheckoutSession): Promise<StorefrontCheckoutView> {
const invoice = session.invoice;
if (!invoice) {
throw new InternalServerErrorException('Checkout session is missing payment invoice');
}
return {
refreshHref: '/shop/checkout',
cancelCheckoutAction: '/shop/checkout/cancel',
totals: deriveCheckoutTotals({
lines: session.lines,
discounts: session.discounts,
invoice
}),
checkoutInvoice: await toStorefrontInvoiceView(invoice),
lines: (session.lines ?? []).map(line => this.toLineView(line)),
discounts: (session.discounts ?? []).map(toStorefrontDiscountView)
};
}
private toLineView({
productId,
variantId,
productTitle,
variantTitle,
thumbnailUrl,
qty,
unitPriceFiat,
lineSubtotalFiat,
deliveryMode
}: CheckoutSessionLine): StorefrontCheckoutLineView {
return {
linkHref: `/shop/products/${productId}/variants/${variantId}`,
thumbnailUrl,
productTitle,
variantTitle,
qty,
unitPriceFiat,
lineSubtotalFiat,
deliveryMode
};
}
}
@@ -0,0 +1,3 @@
export type CheckoutPaymentPollerServiceTest = {
pollCheckoutSessions: () => Promise<void>;
};
@@ -0,0 +1,12 @@
import type { DeliveryMode } from '../../product/types/DeliveryMode';
export type StorefrontCheckoutLineView = {
linkHref: string | null;
thumbnailUrl: string | null;
productTitle: string;
variantTitle: string;
qty: number;
unitPriceFiat: number;
lineSubtotalFiat: number;
deliveryMode: DeliveryMode;
};
@@ -0,0 +1,13 @@
import type { StorefrontInvoiceView } from '../../storefrontCore/types/StorefrontInvoiceView';
import type { StorefrontDiscountView } from '../../storefrontCore/types/StorefrontDiscountView';
import type { CheckoutTotals } from '../../../utils/checkout/types/CheckoutTotals';
import type { StorefrontCheckoutLineView } from './StorefrontCheckoutLineView';
export type StorefrontCheckoutView = {
refreshHref: string;
cancelCheckoutAction: string;
totals: CheckoutTotals;
checkoutInvoice: StorefrontInvoiceView;
lines: StorefrontCheckoutLineView[];
discounts: StorefrontDiscountView[];
};