diff --git a/packages/apps/custom-shipping/README.md b/packages/apps/custom-shipping/README.md index 8271ddc14..e9e9d95e5 100644 --- a/packages/apps/custom-shipping/README.md +++ b/packages/apps/custom-shipping/README.md @@ -1 +1,32 @@ # `@cloudcommerce/app-custom-shipping` + +## Shipping rules + +Each item of `shipping_rules` (app data) may combine: + +| Field | Effect | +|---|---| +| `zip_range.min` / `zip_range.max` | Destination ZIP range | +| `min_amount` | Minimum cart subtotal for the rule to apply | +| `total_price` | Fixed price (or the service `total_price` when unset) | +| `amount_tax` | Percentage of the cart subtotal added to the price | +| `excedent_weight_cost` + `max_cubic_weight` | Price per kg above the weight limit | +| `min_price` | Floor for the final price, e.g. "3% of the cart, at least R$ 150" | + +When two valid rules share the same `service_code`, the cheapest wins, so a +floor can't be emulated with a second rule: use `min_price`. + +Admin settings schema for `min_price` (add to `shipping_rules.schema.items.properties` +on the Market app): + +```json +"min_price": { + "type": "number", + "minimum": 0, + "maximum": 99999999, + "title": "Valor mínimo do frete", + "description": "Piso do frete calculado (ex.: X% do carrinho, com mínimo de R$ Y)" +} +``` + +Unit tests: `pnpm build && bash scripts/tests-unit.sh`. diff --git a/packages/apps/custom-shipping/scripts/tests-unit.sh b/packages/apps/custom-shipping/scripts/tests-unit.sh new file mode 100644 index 000000000..10b2c72f4 --- /dev/null +++ b/packages/apps/custom-shipping/scripts/tests-unit.sh @@ -0,0 +1,15 @@ +#!/bin/bash + +# Shipping rules math is pure, no Store API credentials needed, just stub env +# vars for `@cloudcommerce/firebase` config module. +export SETTINGS_FILEPATH="$(pwd)/tests-unit/settings.json" +export ECOM_STORE_ID="${ECOM_STORE_ID:-1011}" +export ECOM_AUTHENTICATION_ID="${ECOM_AUTHENTICATION_ID:-000000000000000000000000}" +export ECOM_API_KEY="${ECOM_API_KEY:-test}" + +if [ ! -d lib ]; then + echo -e "Run \`pnpm build\` before testing\n" + exit 1 +fi + +node --test tests-unit/ diff --git a/packages/apps/custom-shipping/src/custom-shipping-calculate.ts b/packages/apps/custom-shipping/src/custom-shipping-calculate.ts index 237863e3a..d0d084ab4 100644 --- a/packages/apps/custom-shipping/src/custom-shipping-calculate.ts +++ b/packages/apps/custom-shipping/src/custom-shipping-calculate.ts @@ -72,6 +72,7 @@ export const calculateShipping = async (modBody: AppModuleBody<'calculate_shippi && !rule.disable_free_shipping_from && !(rule.excedent_weight_cost > 0) && !(rule.amount_tax > 0) + && !(rule.min_price > 0) ) { if (!originZip && rule.from && rule.from.zip) { originZip = rule.from.zip; @@ -224,6 +225,10 @@ export const calculateShipping = async (modBody: AppModuleBody<'calculate_shippi if (typeof rule.amount_tax === 'number' && !Number.isNaN(rule.amount_tax)) { rule.total_price += ((rule.amount_tax * amount) / 100); } + // Floor for percentage or weight based rules, e.g. "X% of cart, at least R$ Y" + if (rule.min_price > 0 && rule.total_price < rule.min_price) { + rule.total_price = rule.min_price; + } const currentShippingRule = _shippingRulesByCode[serviceCode]; if (!currentShippingRule || currentShippingRule.total_price > rule.total_price) { _shippingRulesByCode[serviceCode] = rule; @@ -243,6 +248,7 @@ export const calculateShipping = async (modBody: AppModuleBody<'calculate_shippi delete rule.max_cubic_weight; delete rule.excedent_weight_cost; delete rule.amount_tax; + delete rule.min_price; delete rule.label; // also try to find corresponding service object from config diff --git a/packages/apps/custom-shipping/tests-unit/min-price.test.mjs b/packages/apps/custom-shipping/tests-unit/min-price.test.mjs new file mode 100644 index 000000000..0df0cf917 --- /dev/null +++ b/packages/apps/custom-shipping/tests-unit/min-price.test.mjs @@ -0,0 +1,60 @@ +import assert from 'node:assert'; +import test, { describe } from 'node:test'; +import { calculateShipping } from '../lib/custom-shipping-calculate.js'; + +// GO zip range with "3% of cart, at least R$ 150" (Bom Ar own delivery rule) +const rule = () => ({ + service_code: 'GO', + label: 'Entrega Bom Ar', + zip_range: { min: 72800000, max: 76799999 }, + amount_tax: 3, + min_price: 150, + delivery_time: { days: 3, working_days: true }, +}); + +const calc = (subtotal, zip = '74543010', rules = [rule()]) => calculateShipping({ + application: { data: { zip: '74543010', shipping_rules: rules }, hidden_data: {} }, + params: { + to: zip && { zip }, + subtotal, + items: [{ + product_id: '1', sku: 'A', name: 'Split', price: subtotal, quantity: 1, weight: { value: 30, unit: 'kg' }, + }], + }, +}); + +describe('Custom shipping minimum price', () => { + test('uses the floor when the percentage is lower', async () => { + const { shipping_services: services } = await calc(3000); + assert.strictEqual(services.length, 1); + assert.strictEqual(services[0].shipping_line.total_price, 150); + }); + + test('uses the percentage when it is higher than the floor', async () => { + const { shipping_services: services } = await calc(10000); + assert.strictEqual(services[0].shipping_line.total_price, 300); + }); + + test('does not leak min_price into the shipping line', async () => { + const { shipping_services: services } = await calc(3000); + assert.strictEqual(services[0].shipping_line.min_price, undefined); + }); + + test('keeps rules without floor unchanged', async () => { + const noFloor = { ...rule() }; + delete noFloor.min_price; + const { shipping_services: services } = await calc(3000, '74543010', [noFloor]); + assert.strictEqual(services[0].shipping_line.total_price, 90); + }); + + test('ignores destinations outside the zip range', async () => { + const { shipping_services: services } = await calc(3000, '01310100'); + assert.strictEqual(services.length, 0); + }); + + test('rule with only a floor is not a free shipping preview', async () => { + const floorOnly = { service_code: 'GO', zip_range: rule().zip_range, min_price: 150 }; + const res = await calc(3000, null, [floorOnly]); + assert.notStrictEqual(res.free_shipping_from_value, 0); + }); +}); diff --git a/packages/apps/custom-shipping/tests-unit/settings.json b/packages/apps/custom-shipping/tests-unit/settings.json new file mode 100644 index 000000000..cabb34e1c --- /dev/null +++ b/packages/apps/custom-shipping/tests-unit/settings.json @@ -0,0 +1,7 @@ +{ + "name": "Custom shipping test store", + "lang": "pt_br", + "currency": "BRL", + "currency_symbol": "R$", + "country_code": "BR" +} diff --git a/packages/apps/paghiper/scripts/tests.sh b/packages/apps/paghiper/scripts/tests.sh new file mode 100644 index 000000000..67bca9c08 --- /dev/null +++ b/packages/apps/paghiper/scripts/tests.sh @@ -0,0 +1,16 @@ +#!/bin/bash + +# List payments and due date parsing are pure, no PagHiper nor Store API +# credentials needed, just stub env vars for `@cloudcommerce/firebase` config. +export SETTINGS_FILEPATH="$(pwd)/tests/settings.json" +export ECOM_STORE_ID="${ECOM_STORE_ID:-1011}" +export ECOM_AUTHENTICATION_ID="${ECOM_AUTHENTICATION_ID:-000000000000000000000000}" +export ECOM_API_KEY="${ECOM_API_KEY:-test}" +export PAGHIPER_API_KEY="${PAGHIPER_API_KEY:-apk_test}" + +if [ ! -d lib ]; then + echo -e "Run \`pnpm build\` before testing\n" + exit 1 +fi + +node --test tests/ diff --git a/packages/apps/paghiper/src/functions-lib/due-date.ts b/packages/apps/paghiper/src/functions-lib/due-date.ts new file mode 100644 index 000000000..4fb96dda9 --- /dev/null +++ b/packages/apps/paghiper/src/functions-lib/due-date.ts @@ -0,0 +1,20 @@ +/** + * PagHiper returns due dates in Brasília time, with or without the time part + * ("2026-10-08" or "2026-10-08 23:59:59"). `new Date()` alone would read them + * as UTC, ending the checkout countdown three hours early (or the day before). + */ +const parseDueDate = (dueDate?: string | null) => { + if (!dueDate) return undefined; + const value = String(dueDate).trim(); + let date: Date; + if (/^\d{4}-\d{2}-\d{2}$/.test(value)) { + date = new Date(`${value}T23:59:59-03:00`); + } else if (/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2})?$/.test(value)) { + date = new Date(`${value.replace(' ', 'T')}-03:00`); + } else { + date = new Date(value); + } + return Number.isNaN(date.getTime()) ? undefined : date.toISOString(); +}; + +export default parseDueDate; diff --git a/packages/apps/paghiper/src/paghiper-create-transaction.ts b/packages/apps/paghiper/src/paghiper-create-transaction.ts index 5528d8965..50228b739 100644 --- a/packages/apps/paghiper/src/paghiper-create-transaction.ts +++ b/packages/apps/paghiper/src/paghiper-create-transaction.ts @@ -6,6 +6,7 @@ import type { import type { PagHiperApp } from '../types/config-app'; import config, { logger } from '@cloudcommerce/firebase/lib/config'; import createAxios from './functions-lib/create-axios'; +import parseDueDate from './functions-lib/due-date'; type ItemsPagHiper = { description: string, @@ -191,6 +192,11 @@ export default async (appData: AppModuleBody) => { break; } } + const validThru = parseDueDate(createRequest.due_date); + if (validThru) { + // Checkout shows the Pix countdown from `valid_thru` + transaction.account_deposit = { valid_thru: validThru }; + } transaction.notes = `'; } else { @@ -200,8 +206,9 @@ export default async (appData: AppModuleBody) => { code: bankSlip.digitable_line, link: bankSlip.url_slip_pdf, }; - if (createRequest.due_date) { - transaction.banking_billet.valid_thru = new Date(createRequest.due_date).toISOString(); + const validThru = parseDueDate(createRequest.due_date); + if (validThru) { + transaction.banking_billet.valid_thru = validThru; } } diff --git a/packages/apps/paghiper/src/paghiper-list-payments.ts b/packages/apps/paghiper/src/paghiper-list-payments.ts index 50a6309d9..d7d15a82b 100644 --- a/packages/apps/paghiper/src/paghiper-list-payments.ts +++ b/packages/apps/paghiper/src/paghiper-list-payments.ts @@ -60,10 +60,12 @@ export default async (data: AppModuleBody) => { listPaymentMethods.forEach((paymentMethod) => { const isPix = paymentMethod === 'account_deposit'; - const minAmount = configApp.min_amount || isPix ? 3 : 0; + // Configured minimum applies to both methods, Pix has a R$ 3 floor on PagHiper + const minAmount = Math.max(configApp.min_amount || 0, isPix ? 3 : 0); const methodConfig = isPix ? configApp.pix : configApp; - const methodEnable = isPix ? configApp?.pix?.enable : configApp?.pix?.disable_billet; + // Banking billet is listed unless explicitly disabled on Pix options + const methodEnable = isPix ? configApp?.pix?.enable : !configApp?.pix?.disable_billet; // Workaround for showcase const validateAmount = amount.total ? (amount.total >= minAmount) : true; diff --git a/packages/apps/paghiper/tests/list-payments-and-due-date.test.mjs b/packages/apps/paghiper/tests/list-payments-and-due-date.test.mjs new file mode 100644 index 000000000..f22e457f0 --- /dev/null +++ b/packages/apps/paghiper/tests/list-payments-and-due-date.test.mjs @@ -0,0 +1,66 @@ +/* eslint-disable import/no-relative-packages */ +import assert from 'node:assert'; +import test, { describe } from 'node:test'; +import listPayments from '../lib/paghiper-list-payments.js'; +import parseDueDate from '../lib/functions-lib/due-date.js'; + +process.env.PAGHIPER_API_KEY = process.env.PAGHIPER_API_KEY || 'apk_test'; + +const list = (data, total) => listPayments({ + application: { data, hidden_data: {} }, + params: { lang: 'pt_br', amount: { total } }, +}); +const codes = (res) => res.payment_gateways.map((g) => g.payment_method.code); + +describe('PagHiper list payments', () => { + test('lists banking billet by default (no Pix config)', async () => { + assert.deepStrictEqual(codes(await list({}, 100)), ['banking_billet']); + }); + + test('lists billet and Pix when Pix is enabled', async () => { + assert.deepStrictEqual( + codes(await list({ pix: { enable: true } }, 100)), + ['banking_billet', 'account_deposit'], + ); + }); + + test('hides banking billet only when disable_billet is set', async () => { + assert.deepStrictEqual( + codes(await list({ pix: { enable: true, disable_billet: true } }, 100)), + ['account_deposit'], + ); + }); + + test('respects configured minimum amount for both methods', async () => { + const config = { min_amount: 200, pix: { enable: true } }; + assert.deepStrictEqual(codes(await list(config, 150)), []); + assert.deepStrictEqual(codes(await list(config, 250)), ['banking_billet', 'account_deposit']); + }); + + test('keeps R$ 3 floor for Pix without configured minimum', async () => { + assert.deepStrictEqual(codes(await list({ pix: { enable: true } }, 2)), ['banking_billet']); + }); + + test('lists both on showcase requests without amount', async () => { + assert.deepStrictEqual( + codes(await list({ min_amount: 200, pix: { enable: true } })), + ['banking_billet', 'account_deposit'], + ); + }); +}); + +describe('PagHiper due date', () => { + test('date only means end of day in Brasília', () => { + assert.strictEqual(parseDueDate('2026-10-08'), '2026-10-09T02:59:59.000Z'); + }); + + test('date and time are Brasília time', () => { + assert.strictEqual(parseDueDate('2026-10-08 15:30:00'), '2026-10-08T18:30:00.000Z'); + }); + + test('keeps ISO with offset and ignores invalid', () => { + assert.strictEqual(parseDueDate('2026-10-08T15:30:00Z'), '2026-10-08T15:30:00.000Z'); + assert.strictEqual(parseDueDate('amanhã'), undefined); + assert.strictEqual(parseDueDate(undefined), undefined); + }); +}); diff --git a/packages/apps/paghiper/tests/settings.json b/packages/apps/paghiper/tests/settings.json new file mode 100644 index 000000000..b39c46052 --- /dev/null +++ b/packages/apps/paghiper/tests/settings.json @@ -0,0 +1,6 @@ +{ + "name": "PagHiper test store", + "lang": "pt_br", + "currency": "BRL", + "currency_symbol": "R$" +}