diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index ee42c6725..a55c5a0de 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -100,6 +100,14 @@ const run = async () => { const matchIdx = mergedRewrites.findIndex((r) => r.function === userRewrite.function); if (matchIdx >= 0) { mergedRewrites[matchIdx] = { ...mergedRewrites[matchIdx], ...userRewrite }; + return; + } + // Store rewrites must come before the SSR catch-all or they never match + const catchAllIdx = mergedRewrites.findIndex((r) => { + return typeof r.source === 'string' && r.source.startsWith('**'); + }); + if (catchAllIdx >= 0) { + mergedRewrites.splice(catchAllIdx, 0, userRewrite); } else { mergedRewrites.push(userRewrite); } @@ -130,8 +138,10 @@ const run = async () => { }); const $firebase = (cmd: string) => { $.verbose = true; - if (cmd === 'deploy' && !options.length) { - return $`firebase --project=${projectId} ${cmd} --force`; + if (cmd === 'deploy') { + // `--force` also on partial deploys (`--only functions:`), otherwise + // the first deploy of a function with retry policy fails on CI (non-interactive) + return $`firebase --project=${projectId} ${cmd} ${options} --force`; } return $`firebase --project=${projectId} ${cmd} ${options}`; }; diff --git a/packages/firebase/src/config.ts b/packages/firebase/src/config.ts index 1ee37932f..b33f4ff84 100644 --- a/packages/firebase/src/config.ts +++ b/packages/firebase/src/config.ts @@ -1,4 +1,4 @@ -import type { ApiEventName, SettingsContent } from '@cloudcommerce/types'; +import type { ApiEventName, SettingsContent, CustomerSet } from '@cloudcommerce/types'; import { join as joinPath } from 'node:path'; import { existsSync, readFileSync } from 'node:fs'; import { AsyncLocalStorage } from 'node:async_hooks'; @@ -218,10 +218,27 @@ export type AntiFraudConfig = false | { export const checkoutRateLimitsCollection = 'checkoutRateLimits'; +/* Opt-in gates for stores that only sell to registered/approved customers (B2B, wholesale). */ +export type CheckoutGatesConfig = { + /* Reject checkout when the customer doesn't exist yet or `enabled !== true` */ + customersOnly?: boolean; + /* Reject checkout when `customer.staff_signature !== true` (staff approval mark) */ + requireStaffSignature?: boolean; + /* Reject checkout when items subtotal is lower than this value */ + minSubtotal?: number; +}; + +export type PassportConfig = { + /* Fields merged into customers auto-created on first (social/email link) login */ + newCustomer?: Partial; +}; + export const config = _config as { get(): BaseConfig & typeof mergeConfig & { metafields: Record; checkoutAntiFraud?: AntiFraudConfig; + checkout?: CheckoutGatesConfig; + passport?: PassportConfig; }; // eslint-disable-next-line set(config: any): void; diff --git a/packages/firebase/src/handlers/check-store-events.ts b/packages/firebase/src/handlers/check-store-events.ts index 6ea548fd1..e578a104f 100644 --- a/packages/firebase/src/handlers/check-store-events.ts +++ b/packages/firebase/src/handlers/check-store-events.ts @@ -5,12 +5,13 @@ import type { AppEventsPayload, EventsResult, } from '@cloudcommerce/types'; -import type { ApiConfig, ApiError } from '@cloudcommerce/api'; +import type { ApiError } from '@cloudcommerce/api'; import { getFirestore } from 'firebase-admin/firestore'; import { PubSub } from '@google-cloud/pubsub'; import api from '@cloudcommerce/api'; import config, { logger } from '../config'; import { EVENT_SKIP_FLAG, GET_PUBSUB_TOPIC } from '../const'; +import parseEventName from './parse-event-name'; declare global { // eslint-disable-next-line @@ -22,64 +23,6 @@ declare global { ); } -const parseEventName = ( - evName: ApiEventName, - baseApiEventsFilter: Record, -) => { - const [resource, actionName] = evName.split('-'); - const params: ApiConfig['params'] = { ...baseApiEventsFilter }; - const bodySet: { [key: string]: any } = {}; - if (actionName === 'new' || actionName === 'delayed') { - params.action = 'create'; - } else { - switch (resource) { - case 'orders': - switch (actionName) { - case 'paid': - bodySet['financial_status.current'] = 'paid'; - break; - case 'readyForShipping': - bodySet['fulfillment_status.current'] = 'ready_for_shipping'; - break; - case 'shipped': - case 'delivered': - bodySet['fulfillment_status.current'] = actionName; - break; - case 'cancelled': - bodySet.status = 'cancelled'; - break; - default: // anyStatusSet - params.modified_fields = [ - 'financial_status', - 'fulfillment_status', - 'status', - ]; - } - break; - case 'products': - params.modified_fields = actionName === 'priceSet' - ? ['price', 'variations.price'] - : ['quantity']; // quantitySet - break; - case 'carts': - params.modified_fields = ['customers']; // customerSet - break; - case 'applications': - params.modified_fields = ['data', 'hidden_data']; // dataSet - break; - default: - } - } - Object.keys(bodySet).forEach((field) => { - params[`body.${field}`] = bodySet[field]; - }); - return { resource, params, actionName } as { - resource: Resource, - params: Exclude, - actionName: string - }; -}; - const pubSubClient = new PubSub(); const tryPubSubPublish = async ( topicName: string, diff --git a/packages/firebase/src/handlers/parse-event-name.ts b/packages/firebase/src/handlers/parse-event-name.ts new file mode 100644 index 000000000..bcc6a078d --- /dev/null +++ b/packages/firebase/src/handlers/parse-event-name.ts @@ -0,0 +1,69 @@ +import type { ApiEventName, Resource } from '@cloudcommerce/types'; +import type { ApiConfig } from '@cloudcommerce/api'; + +/* Maps an `ApiEventName` to the Store API `events/` query params. +Kept free of side effects (no Firebase, no config) so it can be unit tested. */ +export const parseEventName = ( + evName: ApiEventName, + baseApiEventsFilter: Record, +) => { + const [resource, actionName] = evName.split('-'); + const params: ApiConfig['params'] = { ...baseApiEventsFilter }; + const bodySet: { [key: string]: any } = {}; + if (actionName === 'new' || actionName === 'delayed') { + params.action = 'create'; + } else { + switch (resource) { + case 'orders': + switch (actionName) { + case 'paid': + bodySet['financial_status.current'] = 'paid'; + break; + case 'readyForShipping': + bodySet['fulfillment_status.current'] = 'ready_for_shipping'; + break; + case 'shipped': + case 'delivered': + bodySet['fulfillment_status.current'] = actionName; + break; + case 'cancelled': + bodySet.status = 'cancelled'; + break; + default: // anyStatusSet + params.modified_fields = [ + 'financial_status', + 'fulfillment_status', + 'status', + ]; + } + break; + case 'products': + params.modified_fields = actionName === 'priceSet' + ? ['price', 'variations.price'] + : ['quantity']; // quantitySet + break; + case 'carts': + params.modified_fields = ['customers']; // customerSet + break; + case 'applications': + params.modified_fields = ['data', 'hidden_data']; // dataSet + break; + case 'customers': + params.modified_fields = actionName === 'staffSignatureSet' + ? ['staff_signature'] + : ['enabled']; // enabledSet + break; + default: + } + } + Object.keys(bodySet).forEach((field) => { + params[`body.${field}`] = bodySet[field]; + }); + return { resource, params, actionName } as { + resource: Resource, + params: Exclude, + actionName: string + }; +}; + +export default parseEventName; diff --git a/packages/firebase/tests/parse-event-name.test.ts b/packages/firebase/tests/parse-event-name.test.ts new file mode 100644 index 000000000..b7f28309c --- /dev/null +++ b/packages/firebase/tests/parse-event-name.test.ts @@ -0,0 +1,26 @@ +import { describe, test, expect } from 'vitest'; +import parseEventName from '../src/handlers/parse-event-name'; + +describe('parseEventName', () => { + test('customers-staffSignatureSet watches staff_signature', () => { + const { resource, params } = parseEventName('customers-staffSignatureSet', {}); + expect(resource).toBe('customers'); + expect(params.modified_fields).toEqual(['staff_signature']); + }); + + test('customers-enabledSet watches enabled', () => { + const { params } = parseEventName('customers-enabledSet', {}); + expect(params.modified_fields).toEqual(['enabled']); + }); + + test('customers-new keeps create action', () => { + const { params } = parseEventName('customers-new', {}); + expect(params.action).toBe('create'); + expect(params.modified_fields).toBeUndefined(); + }); + + test('existing mappings unchanged', () => { + expect(parseEventName('products-priceSet', {}).params.modified_fields).toEqual(['price', 'variations.price']); + expect(parseEventName('orders-paid', {}).params['body.financial_status.current']).toBe('paid'); + }); +}); diff --git a/packages/modules/src/firebase/checkout.ts b/packages/modules/src/firebase/checkout.ts index 2c668c56e..fd764433c 100644 --- a/packages/modules/src/firebase/checkout.ts +++ b/packages/modules/src/firebase/checkout.ts @@ -6,11 +6,15 @@ import type { Payment, } from '../types/index'; import { fullName as getFullname } from '@ecomplus/utils'; -import { logger } from '@cloudcommerce/firebase/lib/config'; +import config, { logger } from '@cloudcommerce/firebase/lib/config'; import { checkoutSchema } from '../index'; import { ajv, sendRequestError } from './ajv'; import fixItems from './functions-checkout/fix-items'; import readOrSaveCustomer from './functions-checkout/read-or-save-customer'; +import { + getCustomerGateError, + getSubtotalGateError, +} from './functions-checkout/customer-gates'; import requestModule from './functions-checkout/request-to-module'; import { sendError, @@ -76,12 +80,28 @@ export default async (req: Request, res: Response) => { ) { return sendError(res, 403, 'CKT803', 'Chato'); } + const checkoutGates = config.get().checkout; const savedCustomer = await readOrSaveCustomer({ ...customer, addresses: !shippingAddr.line_address?.includes('***') ? [shippingAddr] : undefined, + }, { + canCreate: !checkoutGates?.customersOnly, }); + const customerGateError = getCustomerGateError(checkoutGates, savedCustomer); + if (customerGateError) { + return sendError( + res, + customerGateError.status, + customerGateError.code, + customerGateError.message, + customerGateError.userMessage, + ); + } + if (!savedCustomer) { + return sendError(res, 403, 'CKT804', 'Checkout is restricted to registered customers'); + } if (savedCustomer.enabled === false) { return sendError(res, 403, 'CKT802', 'Customer is disabled from placing new orders'); } @@ -176,6 +196,16 @@ export default async (req: Request, res: Response) => { if (subtotal <= 0 && items.length < countCheckoutItems) { return sendError(res, 400, 'CKT801', 'Cannot handle checkout, any valid cart item'); } + const subtotalGateError = getSubtotalGateError(checkoutGates, subtotal); + if (subtotalGateError) { + return sendError( + res, + subtotalGateError.status, + subtotalGateError.code, + subtotalGateError.message, + subtotalGateError.userMessage, + ); + } amount.subtotal = subtotal; body.subtotal = subtotal; fixAmount(amount, body, orderBody); diff --git a/packages/modules/src/firebase/functions-checkout/customer-gates.ts b/packages/modules/src/firebase/functions-checkout/customer-gates.ts new file mode 100644 index 000000000..b96fddb67 --- /dev/null +++ b/packages/modules/src/firebase/functions-checkout/customer-gates.ts @@ -0,0 +1,72 @@ +import type { Customers } from '@cloudcommerce/api/types'; +import type { CheckoutGatesConfig } from '@cloudcommerce/firebase/lib/config'; + +export type CheckoutGateError = { + status: number, + code: string, + message: string, + userMessage: { en_us: string, pt_br: string }, +}; + +/* Gates for stores restricted to registered/approved customers. +Without `gates` (default config) nothing changes on checkout. */ +export const getCustomerGateError = ( + gates: CheckoutGatesConfig | undefined, + savedCustomer: Pick | null, +): CheckoutGateError | null => { + if (!gates) return null; + if (gates.customersOnly) { + if (!savedCustomer) { + return { + status: 403, + code: 'CKT804', + message: 'Checkout is restricted to registered customers', + userMessage: { + en_us: 'Only registered customers can place orders', + pt_br: 'Somente clientes cadastrados podem fazer pedidos', + }, + }; + } + if (savedCustomer.enabled !== true) { + return { + status: 403, + code: 'CKT804', + message: 'Customer is not enabled to place orders', + userMessage: { + en_us: 'Your account is not yet enabled to place orders', + pt_br: 'Seu cadastro ainda não está liberado para compras', + }, + }; + } + } + if (gates.requireStaffSignature && savedCustomer?.staff_signature !== true) { + return { + status: 403, + code: 'CKT805', + message: 'Customer is not approved by store staff', + userMessage: { + en_us: 'Your account is not yet approved by the store', + pt_br: 'Seu cadastro ainda não foi aprovado pela loja', + }, + }; + } + return null; +}; + +export const getSubtotalGateError = ( + gates: CheckoutGatesConfig | undefined, + subtotal: number, +): CheckoutGateError | null => { + const minSubtotal = gates?.minSubtotal; + if (!(minSubtotal && minSubtotal > 0) || subtotal >= minSubtotal) return null; + const minFormatted = minSubtotal.toFixed(2); + return { + status: 400, + code: 'CKT806', + message: `Items subtotal is lower than the minimum of ${minFormatted}`, + userMessage: { + en_us: `Minimum order subtotal is ${minFormatted}`, + pt_br: `O pedido mínimo é de ${minFormatted.replace('.', ',')}`, + }, + }; +}; diff --git a/packages/modules/src/firebase/functions-checkout/read-or-save-customer.ts b/packages/modules/src/firebase/functions-checkout/read-or-save-customer.ts index 85a2d498a..308b5b1e3 100644 --- a/packages/modules/src/firebase/functions-checkout/read-or-save-customer.ts +++ b/packages/modules/src/firebase/functions-checkout/read-or-save-customer.ts @@ -5,7 +5,14 @@ import api from '@cloudcommerce/api'; import { logger } from '@cloudcommerce/firebase/lib/config'; type CustomerToSave = CheckoutCustomer & { addresses: Customers['addresses'] }; -const readOrSaveCustomer = async (customer: CustomerToSave) => { +type ReadOrSaveOptions = { + /* When false (customers-only stores) unknown customers are not created and `null` is returned */ + canCreate?: boolean, +}; +const readOrSaveCustomer = async ( + customer: CustomerToSave, + { canCreate = true }: ReadOrSaveOptions = {}, +): Promise => { const customerEndpoint = customer._id?.length === 24 ? `customers/${customer._id}` as `customers/${Customers['_id']}` : `customers/main_email:${customer.main_email}` as `customers/${string}:${string}`; @@ -37,6 +44,9 @@ const readOrSaveCustomer = async (customer: CustomerToSave) => { logger.error(err); } } + if (!canCreate) { + return null; + } const newCustomer = { display_name: customer.name.given_name || 'visitor', ...customer, diff --git a/packages/modules/tests/3-customer-gates.test.ts b/packages/modules/tests/3-customer-gates.test.ts new file mode 100644 index 000000000..f09aa5685 --- /dev/null +++ b/packages/modules/tests/3-customer-gates.test.ts @@ -0,0 +1,40 @@ +import { describe, test, expect } from 'vitest'; +import { + getCustomerGateError, + getSubtotalGateError, +} from '../src/firebase/functions-checkout/customer-gates'; + +describe('Checkout customer gates (customers-only stores)', () => { + test('no gates config keeps default behavior', () => { + expect(getCustomerGateError(undefined, null)).toBeNull(); + expect(getCustomerGateError({}, { enabled: false })).toBeNull(); + expect(getSubtotalGateError(undefined, 0)).toBeNull(); + }); + + test('customersOnly rejects unknown customer', () => { + const err = getCustomerGateError({ customersOnly: true }, null); + expect(err?.status).toBe(403); + expect(err?.code).toBe('CKT804'); + }); + + test('customersOnly requires enabled === true', () => { + expect(getCustomerGateError({ customersOnly: true }, { enabled: false })?.code).toBe('CKT804'); + expect(getCustomerGateError({ customersOnly: true }, {})?.code).toBe('CKT804'); + expect(getCustomerGateError({ customersOnly: true }, { enabled: true })).toBeNull(); + }); + + test('requireStaffSignature rejects self-enabled customers', () => { + const gates = { customersOnly: true, requireStaffSignature: true }; + expect(getCustomerGateError(gates, { enabled: true })?.code).toBe('CKT805'); + expect(getCustomerGateError(gates, { enabled: true, staff_signature: false })?.code).toBe('CKT805'); + expect(getCustomerGateError(gates, { enabled: true, staff_signature: true })).toBeNull(); + }); + + test('minSubtotal rejects lower subtotals only', () => { + const gates = { minSubtotal: 300 }; + expect(getSubtotalGateError(gates, 299.99)?.code).toBe('CKT806'); + expect(getSubtotalGateError(gates, 299.99)?.userMessage.pt_br).toContain('300,00'); + expect(getSubtotalGateError(gates, 300)).toBeNull(); + expect(getSubtotalGateError({ minSubtotal: 0 }, 1)).toBeNull(); + }); +}); diff --git a/packages/passport/src/firebase/authenticate-customer.ts b/packages/passport/src/firebase/authenticate-customer.ts index b1c86e292..6549b855f 100644 --- a/packages/passport/src/firebase/authenticate-customer.ts +++ b/packages/passport/src/firebase/authenticate-customer.ts @@ -4,6 +4,8 @@ import { getAuth } from 'firebase-admin/auth'; import { logger } from 'firebase-functions/v1'; import api from '@cloudcommerce/api'; import getEnv from '@cloudcommerce/firebase/lib/env'; +import config from '@cloudcommerce/firebase/lib/config'; +import getNewCustomerBody from './new-customer'; export const findCustomerByEmail = async ( email: string, @@ -110,15 +112,17 @@ export const authenticateWithFirebase = async (firebaseAuthToken: string) => { if (foundCustomer) { return getCustomerToken(foundCustomer); } - const { data: newCustomer } = await api.post('customers', { - display_name: name || '', - main_email: email, - emails: [{ - address: email, - verified: isEmailVerified, - }], - }); - return generateAccessToken(newCustomer._id); + const newCustomerBody = getNewCustomerBody( + { name, email, email_verified: isEmailVerified }, + config.get().passport?.newCustomer, + ); + const { data: newCustomer } = await api.post('customers', newCustomerBody); + // Respect `login`/`enabled` defaults (may be restricted by store config) + return getCustomerToken({ + _id: newCustomer._id, + login: newCustomerBody.login, + enabled: newCustomerBody.enabled, + } as Parameters[0]); } // TODO: Find customer by phone number, generate token if found, otherwise unauthorize } diff --git a/packages/passport/src/firebase/new-customer.ts b/packages/passport/src/firebase/new-customer.ts new file mode 100644 index 000000000..e83781262 --- /dev/null +++ b/packages/passport/src/firebase/new-customer.ts @@ -0,0 +1,27 @@ +import type { CustomerSet } from '@cloudcommerce/types'; + +export type FirebaseUserInfo = { + name?: string, + email: string, + email_verified?: boolean, +}; + +/* Body for customers auto-created on first login. Store config +(`passport.newCustomer`) can force fields such as `enabled: false` +for stores that must approve customers before selling. */ +export const getNewCustomerBody = ( + { name, email, email_verified: isEmailVerified }: FirebaseUserInfo, + defaults?: Partial, +): CustomerSet => { + return { + display_name: name || '', + main_email: email, + emails: [{ + address: email, + verified: !!isEmailVerified, + }], + ...defaults, + }; +}; + +export default getNewCustomerBody; diff --git a/packages/passport/tests/new-customer.test.ts b/packages/passport/tests/new-customer.test.ts new file mode 100644 index 000000000..e56f4d264 --- /dev/null +++ b/packages/passport/tests/new-customer.test.ts @@ -0,0 +1,21 @@ +import { describe, test, expect } from 'vitest'; +import getNewCustomerBody from '../src/firebase/new-customer'; + +describe('getNewCustomerBody', () => { + const user = { name: 'Ana', email: 'ana@example.com', email_verified: true }; + + test('default body without store config', () => { + expect(getNewCustomerBody(user)).toEqual({ + display_name: 'Ana', + main_email: 'ana@example.com', + emails: [{ address: 'ana@example.com', verified: true }], + }); + }); + + test('store config can create customers not yet enabled', () => { + const body = getNewCustomerBody(user, { enabled: false, staff_signature: false }); + expect(body.enabled).toBe(false); + expect(body.staff_signature).toBe(false); + expect(body.main_email).toBe('ana@example.com'); + }); +}); diff --git a/packages/storefront/src/lib/layouts/BaseHead.astro b/packages/storefront/src/lib/layouts/BaseHead.astro index 9a2d15e7a..e12561dd4 100644 --- a/packages/storefront/src/lib/layouts/BaseHead.astro +++ b/packages/storefront/src/lib/layouts/BaseHead.astro @@ -207,20 +207,24 @@ if (apiContext.resource === 'products' && apiContext.doc) { sku: product.sku, description, name: getName(product), - offers: { + }; + const productPrice = getPrice(product); + // Products without a (public) price must not advertise an offer to search engines + if (productPrice > 0) { + productJSONLd.offers = { '@type': 'Offer', url: canonicalUrl, availability: `${(checkInStock(product) ? 'In' : 'OutOf')}Stock`, priceCurrency: settings.currency, - price: getPrice(product), + price: productPrice, itemCondition: 'http://schema.org/' + `${(product.condition !== 'new' ? 'Used' : 'New')}Condition`, seller: { '@type': 'Organization', name: settings.name, }, - }, - }; + }; + } if (product.brands?.length) { productJSONLd.brand = { '@type': 'Brand', diff --git a/packages/types/index.ts b/packages/types/index.ts index ce23e218a..e6df03814 100644 --- a/packages/types/index.ts +++ b/packages/types/index.ts @@ -92,6 +92,8 @@ export type ApiEventName = 'orders-new' | 'carts-customerSet' | 'carts-delayed' | 'customers-new' + | 'customers-enabledSet' + | 'customers-staffSignatureSet' | 'applications-dataSet'; export type AppEventsPayload = {