Compare commits
3
Commits
7b62bc41ad
...
7222d0c9be
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7222d0c9be | ||
|
|
302c3729c4 | ||
|
|
3b6323658e |
@@ -0,0 +1,6 @@
|
||||
import { PaymentMethod } from '../modules/payment/types/PaymentMethod';
|
||||
|
||||
export const paymentMethodLabel: Record<PaymentMethod, string> = {
|
||||
[PaymentMethod.Xmr]: 'XMR',
|
||||
[PaymentMethod.Btc]: 'BTC'
|
||||
};
|
||||
@@ -3,14 +3,13 @@ import { DiscountCodesModule } from '../discountCode/DiscountCodesModule';
|
||||
import { ProductsModule } from '../product/ProductsModule';
|
||||
import { StorefrontCoreModule } from '../storefrontCore/StorefrontCoreModule';
|
||||
import { StorefrontProductModule } from '../storefrontProduct/StorefrontProductModule';
|
||||
import { ExchangeRateModule } from '../exchangeRate/ExchangeRateModule';
|
||||
import { StorefrontCartController } from './controllers/StorefrontCartController';
|
||||
import { StorefrontCartDiscountResolver } from './services/StorefrontCartDiscountResolver';
|
||||
import { StorefrontCartService } from './services/StorefrontCartService';
|
||||
import { StorefrontDiscountService } from './services/StorefrontDiscountService';
|
||||
|
||||
@Module({
|
||||
imports: [StorefrontCoreModule, StorefrontProductModule, ProductsModule, DiscountCodesModule, ExchangeRateModule],
|
||||
imports: [StorefrontCoreModule, StorefrontProductModule, ProductsModule, DiscountCodesModule],
|
||||
controllers: [StorefrontCartController],
|
||||
providers: [StorefrontCartService, StorefrontDiscountService, StorefrontCartDiscountResolver],
|
||||
exports: [StorefrontCartService]
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { Body, Controller, Get, HttpStatus, Post, Req, Res, UseFilters } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Throttle } from '@nestjs/throttler';
|
||||
import type { Request, Response } from 'express';
|
||||
import { throttleProfiles } from '../../../config/throttleProfiles';
|
||||
import type { Config } from '../../../types/Config';
|
||||
import { paymentMethodLabel } from '../../../consts/paymentMethodLabel';
|
||||
import { StorefrontExceptionFilter } from '../../storefrontCore/filters/StorefrontExceptionFilter';
|
||||
import { StorefrontCartCookieService } from '../../storefrontCore/services/StorefrontCartCookieService';
|
||||
import { StorefrontCheckoutSessionCookieService } from '../../storefrontCore/services/StorefrontCheckoutSessionCookieService';
|
||||
@@ -33,7 +36,8 @@ export class StorefrontCartController {
|
||||
private readonly feedbackCookieService: StorefrontFeedbackCookieService,
|
||||
private readonly checkoutSessionCookieService: StorefrontCheckoutSessionCookieService,
|
||||
private readonly captchaService: StorefrontCaptchaService,
|
||||
private readonly captchaCookieService: StorefrontCaptchaCookieService
|
||||
private readonly captchaCookieService: StorefrontCaptchaCookieService,
|
||||
private readonly configService: ConfigService
|
||||
) {}
|
||||
|
||||
@Get('shop/cart')
|
||||
@@ -62,9 +66,17 @@ export class StorefrontCartController {
|
||||
|
||||
this.captchaCookieService.setAnswer(req, res, encryptedAnswer);
|
||||
|
||||
const { enabledPaymentMethods } = this.configService.get('shopSettings') as Config['shopSettings'];
|
||||
|
||||
const paymentMethods = enabledPaymentMethods.map(paymentMethod => ({
|
||||
paymentMethod,
|
||||
paymentMethodLabel: paymentMethodLabel[paymentMethod]
|
||||
}));
|
||||
|
||||
return res.render('cart-summary', {
|
||||
...summary,
|
||||
...shopLocals,
|
||||
paymentMethods,
|
||||
captchaSvg
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { DeliveryMode } from '../../product/types/DeliveryMode';
|
||||
import type { StorefrontProductsService } from '../../storefrontProduct/services/StorefrontProductsService';
|
||||
import type { ExchangeRateService } from '../../exchangeRate/services/ExchangeRateService';
|
||||
import { PaymentMethod } from '../../payment/types/PaymentMethod';
|
||||
import type { CookieCartLineExtended } from '../types/CookieCartLineExtended';
|
||||
import type { StorefrontDiscountService } from './StorefrontDiscountService';
|
||||
import { StorefrontCartService } from './StorefrontCartService';
|
||||
@@ -30,9 +28,6 @@ describe('StorefrontCartService', () => {
|
||||
getStorefrontVariantsByIds: jest.Mock;
|
||||
getStorefrontVariant: jest.Mock;
|
||||
};
|
||||
let exchangeRateService: {
|
||||
getLiveFiatPerCrypto: jest.Mock;
|
||||
};
|
||||
let discountService: {
|
||||
getDiscountStateForCart: jest.Mock;
|
||||
applyDiscountCode: jest.Mock;
|
||||
@@ -44,10 +39,6 @@ describe('StorefrontCartService', () => {
|
||||
getStorefrontVariant: jest.fn().mockResolvedValue(buildVariant())
|
||||
};
|
||||
|
||||
exchangeRateService = {
|
||||
getLiveFiatPerCrypto: jest.fn().mockReturnValue(150)
|
||||
};
|
||||
|
||||
discountService = {
|
||||
getDiscountStateForCart: jest.fn().mockResolvedValue({
|
||||
discounts: [],
|
||||
@@ -59,7 +50,6 @@ describe('StorefrontCartService', () => {
|
||||
|
||||
service = new StorefrontCartService(
|
||||
productsService as unknown as StorefrontProductsService,
|
||||
exchangeRateService as unknown as ExchangeRateService,
|
||||
discountService as unknown as StorefrontDiscountService
|
||||
);
|
||||
});
|
||||
@@ -99,8 +89,6 @@ describe('StorefrontCartService', () => {
|
||||
discounts: [],
|
||||
cartDiscountTotal: 0,
|
||||
cartTotalPrice: 0,
|
||||
cartTotalXmr: null,
|
||||
fiatPerXmr: null,
|
||||
hasManualLines: false,
|
||||
hasAutoLines: false,
|
||||
cartTotalIssueMessage: null,
|
||||
@@ -108,23 +96,13 @@ describe('StorefrontCartService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('converts the discounted total to XMR when a live rate is available', async () => {
|
||||
it('returns cart totals and delivery flags', async () => {
|
||||
const summary = await service.getCartSummary([{ variantId, qty: 1 }], []);
|
||||
|
||||
expect(summary.cartSubtotal).toBe(10);
|
||||
expect(summary.cartTotalPrice).toBe(10);
|
||||
expect(summary.fiatPerXmr).toBe(150);
|
||||
expect(summary.cartTotalXmr).toBe('0.06666667');
|
||||
expect(summary.hasAutoLines).toBe(true);
|
||||
});
|
||||
|
||||
it('leaves cartTotalXmr null when no live rate is available', async () => {
|
||||
exchangeRateService.getLiveFiatPerCrypto.mockReturnValue(null);
|
||||
|
||||
const summary = await service.getCartSummary([{ variantId, qty: 1 }], []);
|
||||
|
||||
expect(summary.cartTotalXmr).toBeNull();
|
||||
expect(summary.fiatPerXmr).toBeNull();
|
||||
expect(summary.hasIssues).toBe(false);
|
||||
});
|
||||
|
||||
it('flags manual and auto delivery lines separately', async () => {
|
||||
|
||||
@@ -7,9 +7,6 @@ import type { CookieCart } from '../../storefrontCore/types/cart/CookieCart';
|
||||
import { getQtyByVariantIdFromCart } from '../../../utils/cart/getQtyByVariantIdFromCart';
|
||||
import { DeliveryMode } from '../../product/types/DeliveryMode';
|
||||
import { StorefrontProductsService } from '../../storefrontProduct/services/StorefrontProductsService';
|
||||
import { ExchangeRateService } from '../../exchangeRate/services/ExchangeRateService';
|
||||
import { PaymentMethod } from '../../payment/types/PaymentMethod';
|
||||
import { convertFiatToXmr } from '../../../utils/monero/convertFiatToXmr';
|
||||
import { CookieCartLineDto } from '../dto/CookieCartLineDto';
|
||||
import type { CookieCartExtended } from '../types/CookieCartExtended';
|
||||
import type { CookieCartLineExtended } from '../types/CookieCartLineExtended';
|
||||
@@ -23,7 +20,6 @@ import { StorefrontDiscountService } from './StorefrontDiscountService';
|
||||
export class StorefrontCartService {
|
||||
constructor(
|
||||
private readonly productsService: StorefrontProductsService,
|
||||
private readonly exchangeRateService: ExchangeRateService,
|
||||
private readonly discountService: StorefrontDiscountService
|
||||
) {}
|
||||
|
||||
@@ -67,8 +63,6 @@ export class StorefrontCartService {
|
||||
discounts: [],
|
||||
cartDiscountTotal: 0,
|
||||
cartTotalPrice: 0,
|
||||
cartTotalXmr: null,
|
||||
fiatPerXmr: null,
|
||||
hasManualLines: false,
|
||||
hasAutoLines: false,
|
||||
cartTotalIssueMessage: null,
|
||||
@@ -78,8 +72,6 @@ export class StorefrontCartService {
|
||||
|
||||
const cartSubtotal = sumByKey(cartExtended, 'lineSubtotal');
|
||||
const discountState = await this.discountService.getDiscountStateForCart(discountCodes, cartExtended);
|
||||
const fiatPerXmr = this.exchangeRateService.getLiveFiatPerCrypto(PaymentMethod.Xmr);
|
||||
const cartTotalXmr = fiatPerXmr !== null ? convertFiatToXmr(discountState.cartTotalPrice, fiatPerXmr) : null;
|
||||
const hasManualLines = cartExtended.some(line => line.deliveryMode === DeliveryMode.Manual);
|
||||
const hasAutoLines = cartExtended.some(line => line.deliveryMode === DeliveryMode.Auto);
|
||||
const cartTotalIssueMessage = getZeroCartTotalIssue(discountState.cartTotalPrice, cartExtended.length > 0);
|
||||
@@ -92,8 +84,6 @@ export class StorefrontCartService {
|
||||
cartExtended,
|
||||
cartSubtotal,
|
||||
...discountState,
|
||||
cartTotalXmr,
|
||||
fiatPerXmr,
|
||||
hasManualLines,
|
||||
hasAutoLines,
|
||||
cartTotalIssueMessage,
|
||||
|
||||
@@ -4,8 +4,6 @@ import type { CookieCartExtended } from './CookieCartExtended';
|
||||
export type CookieCartSummary = {
|
||||
cartExtended: CookieCartExtended;
|
||||
cartSubtotal: number;
|
||||
cartTotalXmr: string | null;
|
||||
fiatPerXmr: number | null;
|
||||
hasManualLines: boolean;
|
||||
hasAutoLines: boolean;
|
||||
cartTotalIssueMessage: string | null;
|
||||
|
||||
@@ -8,6 +8,7 @@ import { StorefrontFeedbackCookieService } from './StorefrontFeedbackCookieServi
|
||||
import { StorefrontOrderAuthCookieService } from './StorefrontOrderAuthCookieService';
|
||||
import { StorefrontThemeCookieService } from './StorefrontThemeCookieService';
|
||||
import type { StorefrontPageMetaInput } from '../types/StorefrontPageMetaInput';
|
||||
import { PaymentMethod } from '../../payment/types/PaymentMethod';
|
||||
|
||||
describe('StorefrontShopViewService', () => {
|
||||
type ServiceOverrides = {
|
||||
@@ -21,8 +22,9 @@ describe('StorefrontShopViewService', () => {
|
||||
simplexLink: string | null;
|
||||
shippingNote: string | null;
|
||||
};
|
||||
shopSettings?: { shopName: string; shopFiatCurrency: string };
|
||||
shopSettings?: { shopName: string; shopFiatCurrency: string; enabledPaymentMethods?: PaymentMethod[] };
|
||||
fiatPerXmr?: number;
|
||||
fiatPerBtc?: number | null;
|
||||
req?: Partial<Pick<Request, 'protocol' | 'path' | 'originalUrl'>> & {
|
||||
host?: string;
|
||||
};
|
||||
@@ -45,13 +47,20 @@ describe('StorefrontShopViewService', () => {
|
||||
simplexLink: null,
|
||||
shippingNote: null
|
||||
},
|
||||
shopSettings = { shopName: 'Demo Shop', shopFiatCurrency: 'USD' },
|
||||
shopSettings = {
|
||||
shopName: 'Demo Shop',
|
||||
shopFiatCurrency: 'USD',
|
||||
enabledPaymentMethods: [PaymentMethod.Xmr, PaymentMethod.Btc]
|
||||
},
|
||||
fiatPerXmr = 150,
|
||||
fiatPerBtc = 60_000,
|
||||
req: reqOverrides = {}
|
||||
} = overrides;
|
||||
|
||||
const exchangeRateService = {
|
||||
getLiveFiatPerCrypto: jest.fn().mockReturnValue(fiatPerXmr)
|
||||
getLiveFiatPerCrypto: jest.fn((method: PaymentMethod) =>
|
||||
method === PaymentMethod.Btc ? fiatPerBtc : fiatPerXmr
|
||||
)
|
||||
} as unknown as ExchangeRateService;
|
||||
const configService = {
|
||||
get: jest.fn().mockReturnValue(shopSettings)
|
||||
@@ -124,7 +133,8 @@ describe('StorefrontShopViewService', () => {
|
||||
simplexLink: 'https://simplex.example',
|
||||
shippingNote: 'Ships in 3 days'
|
||||
},
|
||||
fiatPerXmr: 200
|
||||
fiatPerXmr: 200,
|
||||
fiatPerBtc: 80_000
|
||||
});
|
||||
|
||||
const locals = await service.buildShopRenderLocals(req, res, defaultPage);
|
||||
@@ -134,7 +144,10 @@ describe('StorefrontShopViewService', () => {
|
||||
cartTotalQty: 3,
|
||||
feedback: { type: 'success', text: 'Added to cart' },
|
||||
shopFiatCurrency: 'USD',
|
||||
fiatPerXmr: 200,
|
||||
cryptoRates: [
|
||||
{ cryptoCurrency: 'XMR', fiatPerCrypto: 200 },
|
||||
{ cryptoCurrency: 'BTC', fiatPerCrypto: 80_000 }
|
||||
],
|
||||
logoUrl: '/uploads/logo.png',
|
||||
faviconUrl: '/uploads/favicon.ico',
|
||||
simplexLink: 'https://simplex.example',
|
||||
@@ -264,7 +277,7 @@ describe('StorefrontShopViewService', () => {
|
||||
|
||||
it('uses shop fiat currency in product json-ld', async () => {
|
||||
const { service, req, res } = createService({
|
||||
shopSettings: { shopName: 'Euro Shop', shopFiatCurrency: 'EUR' },
|
||||
shopSettings: { shopName: 'Euro Shop', shopFiatCurrency: 'EUR', enabledPaymentMethods: [PaymentMethod.Xmr] },
|
||||
req: {
|
||||
path: '/shop/products/1/variants/2',
|
||||
originalUrl: '/shop/products/1/variants/2'
|
||||
|
||||
@@ -8,9 +8,11 @@ import { formatShortOrderId } from '../../../utils/order/formatShortOrderId';
|
||||
import { toAbsoluteUrl } from '../../../utils/toAbsoluteUrl';
|
||||
import { ShopSettingsService } from '../../shopSettings/services/ShopSettingsService';
|
||||
import { ExchangeRateService } from '../../exchangeRate/services/ExchangeRateService';
|
||||
import { paymentMethodLabel } from '../../../consts/paymentMethodLabel';
|
||||
import { PaymentMethod } from '../../payment/types/PaymentMethod';
|
||||
import type { AuthorizedOrderNavItem } from '../types/AuthorizedOrderNavItem';
|
||||
import type { ShopRenderLocals } from '../types/ShopRenderLocals';
|
||||
import type { StorefrontCryptoRate } from '../types/StorefrontCryptoRate';
|
||||
import type { StorefrontPageMeta } from '../types/StorefrontPageMeta';
|
||||
import type { StorefrontPageMetaInput } from '../types/StorefrontPageMetaInput';
|
||||
import type { StorefrontProductJsonLdInput } from '../types/StorefrontProductJsonLdInput';
|
||||
@@ -39,9 +41,11 @@ export class StorefrontShopViewService {
|
||||
|
||||
const cartTotalQty = getTotalCartQtyFromCart(cart);
|
||||
|
||||
const { shopName, shopFiatCurrency } = this.configService.get('shopSettings') as Config['shopSettings'];
|
||||
const { shopName, shopFiatCurrency, enabledPaymentMethods } = this.configService.get(
|
||||
'shopSettings'
|
||||
) as Config['shopSettings'];
|
||||
|
||||
const fiatPerXmr = this.exchangeRateService.getLiveFiatPerCrypto(PaymentMethod.Xmr);
|
||||
const cryptoRates = this.buildCryptoRates(enabledPaymentMethods);
|
||||
|
||||
const { logoUrl, faviconUrl, simplexLink, shippingNote } =
|
||||
await this.shopSettingsService.getStorefrontBranding();
|
||||
@@ -61,7 +65,7 @@ export class StorefrontShopViewService {
|
||||
authorizedOrders,
|
||||
shopNavActive,
|
||||
shopFiatCurrency,
|
||||
fiatPerXmr,
|
||||
cryptoRates,
|
||||
logoUrl,
|
||||
faviconUrl,
|
||||
simplexLink,
|
||||
@@ -130,4 +134,21 @@ export class StorefrontShopViewService {
|
||||
|
||||
return JSON.stringify(data);
|
||||
}
|
||||
|
||||
private buildCryptoRates(enabledPaymentMethods: PaymentMethod[]): StorefrontCryptoRate[] {
|
||||
return enabledPaymentMethods.flatMap(paymentMethod => {
|
||||
const fiatPerCrypto = this.exchangeRateService.getLiveFiatPerCrypto(paymentMethod);
|
||||
|
||||
if (fiatPerCrypto === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
cryptoCurrency: paymentMethodLabel[paymentMethod],
|
||||
fiatPerCrypto
|
||||
}
|
||||
];
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ShopFiatCurrency } from '../../../types/ShopFiatCurrency';
|
||||
import type { AuthorizedOrderNavItem } from './AuthorizedOrderNavItem';
|
||||
import type { ShopNavActive } from './ShopNavActive';
|
||||
import type { StorefrontCryptoRate } from './StorefrontCryptoRate';
|
||||
import type { StorefrontFeedback } from './StorefrontFeedback';
|
||||
import type { StorefrontPageMeta } from './StorefrontPageMeta';
|
||||
import type { StorefrontThemePreference } from './StorefrontThemePreference';
|
||||
@@ -13,7 +14,7 @@ export type ShopRenderLocals = {
|
||||
authorizedOrders: AuthorizedOrderNavItem[];
|
||||
shopNavActive: ShopNavActive;
|
||||
shopFiatCurrency: ShopFiatCurrency;
|
||||
fiatPerXmr: number | null;
|
||||
cryptoRates: StorefrontCryptoRate[];
|
||||
logoUrl: string | null;
|
||||
faviconUrl: string | null;
|
||||
simplexLink: string | null;
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
export type StorefrontCryptoRate = {
|
||||
cryptoCurrency: string;
|
||||
fiatPerCrypto: number;
|
||||
};
|
||||
@@ -52,13 +52,6 @@
|
||||
{{cartTotalPrice}}
|
||||
{{shopFiatCurrency}}
|
||||
</p>
|
||||
{{#if cartTotalXmr}}
|
||||
<p class='sf-text-muted'>= {{cartTotalXmr}} XMR</p>
|
||||
{{else}}
|
||||
<p class='sf-text-muted'>
|
||||
We couldn't determine the XMR rate right now. Please try again later.
|
||||
</p>
|
||||
{{/if}}
|
||||
|
||||
<form class='sf-form-row' method='post' action='/shop/cart/discount'>
|
||||
<label class='sf-field'>
|
||||
@@ -73,22 +66,30 @@
|
||||
{{/if}}
|
||||
|
||||
<div class='sf-stack sf-mt-4'>
|
||||
{{#if cartTotalXmr}}
|
||||
{{#unless hasIssues}}
|
||||
<form class='sf-form-col' method='post' action='/shop/checkout/pay'>
|
||||
<div>{{{captchaSvg}}}</div>
|
||||
<label class='sf-field'>
|
||||
<span class='sf-field__label'>Enter the code above</span>
|
||||
<input class='sf-input' type='text' name='captcha' autocomplete='off' required />
|
||||
</label>
|
||||
<button type='submit' class='sf-btn sf-btn--primary'>Pay with XMR</button>
|
||||
</form>
|
||||
{{else}}
|
||||
<button type='button' class='sf-btn sf-btn--primary' disabled title='Resolve cart issues before paying'>Pay with XMR</button>
|
||||
{{/unless}}
|
||||
{{#unless hasIssues}}
|
||||
<form class='sf-form-col' method='post' action='/shop/checkout/pay'>
|
||||
<div>{{{captchaSvg}}}</div>
|
||||
<label class='sf-field'>
|
||||
<span class='sf-field__label'>Enter the code above</span>
|
||||
<input class='sf-input' type='text' name='captcha' autocomplete='off' required />
|
||||
</label>
|
||||
|
||||
<div class='sf-stack'>
|
||||
{{#each paymentMethods}}
|
||||
<button
|
||||
type='submit'
|
||||
name='paymentMethod'
|
||||
value='{{paymentMethod}}'
|
||||
class='sf-btn sf-btn--primary'
|
||||
>
|
||||
Pay with {{paymentMethodLabel}}
|
||||
</button>
|
||||
{{/each}}
|
||||
</div>
|
||||
</form>
|
||||
{{else}}
|
||||
<button type='button' class='sf-btn sf-btn--primary' disabled title='XMR rate unavailable'>Pay with XMR</button>
|
||||
{{/if}}
|
||||
<button type='button' class='sf-btn sf-btn--primary' disabled title='Resolve cart issues before paying'>Continue to payment</button>
|
||||
{{/unless}}
|
||||
|
||||
{{> manual-shipping-info}}
|
||||
{{> auto-delivery-info}}
|
||||
|
||||
@@ -29,8 +29,12 @@
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{{#if fiatPerXmr}}
|
||||
<div class='sf-rate'>1 XMR = {{fiatPerXmr}} {{shopFiatCurrency}}</div>
|
||||
{{#if cryptoRates.length}}
|
||||
<div class='sf-rates'>
|
||||
{{#each cryptoRates}}
|
||||
<div class='sf-rate'>1 {{cryptoCurrency}} = {{fiatPerCrypto}} {{../shopFiatCurrency}}</div>
|
||||
{{/each}}
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
Reference in New Issue
Block a user