Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down Expand Up @@ -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:<codebase>`), 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}`;
};
Expand Down
19 changes: 18 additions & 1 deletion packages/firebase/src/config.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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<CustomerSet>;
};

export const config = _config as {
get(): BaseConfig & typeof mergeConfig & {
metafields: Record<string, any>;
checkoutAntiFraud?: AntiFraudConfig;
checkout?: CheckoutGatesConfig;
passport?: PassportConfig;
};
// eslint-disable-next-line
set(config: any): void;
Expand Down
61 changes: 2 additions & 59 deletions packages/firebase/src/handlers/check-store-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -22,64 +23,6 @@ declare global {
);
}

const parseEventName = (
evName: ApiEventName,
baseApiEventsFilter: Record<string, string>,
) => {
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<ApiConfig['params'], undefined | string>,
actionName: string
};
};

const pubSubClient = new PubSub();
const tryPubSubPublish = async (
topicName: string,
Expand Down
69 changes: 69 additions & 0 deletions packages/firebase/src/handlers/parse-event-name.ts
Original file line number Diff line number Diff line change
@@ -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/<resource>` query params.
Kept free of side effects (no Firebase, no config) so it can be unit tested. */
export const parseEventName = (
evName: ApiEventName,
baseApiEventsFilter: Record<string, string>,
) => {
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<ApiConfig['params'], undefined | string>,
actionName: string
};
};

export default parseEventName;
26 changes: 26 additions & 0 deletions packages/firebase/tests/parse-event-name.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
32 changes: 31 additions & 1 deletion packages/modules/src/firebase/checkout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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');
}
Expand Down Expand Up @@ -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);
Expand Down
72 changes: 72 additions & 0 deletions packages/modules/src/firebase/functions-checkout/customer-gates.ts
Original file line number Diff line number Diff line change
@@ -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<Customers, 'enabled' | 'staff_signature'> | 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('.', ',')}`,
},
};
};
Loading
Loading