init
This commit is contained in:
@@ -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() });
|
||||
}
|
||||
}
|
||||
+131
@@ -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[];
|
||||
};
|
||||
Reference in New Issue
Block a user