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
31 changes: 31 additions & 0 deletions packages/apps/custom-shipping/README.md
Original file line number Diff line number Diff line change
@@ -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`.
15 changes: 15 additions & 0 deletions packages/apps/custom-shipping/scripts/tests-unit.sh
Original file line number Diff line number Diff line change
@@ -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/
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -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
Expand Down
60 changes: 60 additions & 0 deletions packages/apps/custom-shipping/tests-unit/min-price.test.mjs
Original file line number Diff line number Diff line change
@@ -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);
});
});
7 changes: 7 additions & 0 deletions packages/apps/custom-shipping/tests-unit/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"name": "Custom shipping test store",
"lang": "pt_br",
"currency": "BRL",
"currency_symbol": "R$",
"country_code": "BR"
}
16 changes: 16 additions & 0 deletions packages/apps/paghiper/scripts/tests.sh
Original file line number Diff line number Diff line change
@@ -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/
20 changes: 20 additions & 0 deletions packages/apps/paghiper/src/functions-lib/due-date.ts
Original file line number Diff line number Diff line change
@@ -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;
11 changes: 9 additions & 2 deletions packages/apps/paghiper/src/paghiper-create-transaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 = `<img src="${pixCode.qrcode_image_url}" `
+ 'style="display:block;max-width:100%;margin:0 auto" />';
} else {
Expand All @@ -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;
}
}

Expand Down
6 changes: 4 additions & 2 deletions packages/apps/paghiper/src/paghiper-list-payments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
66 changes: 66 additions & 0 deletions packages/apps/paghiper/tests/list-payments-and-due-date.test.mjs
Original file line number Diff line number Diff line change
@@ -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);
});
});
6 changes: 6 additions & 0 deletions packages/apps/paghiper/tests/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"name": "PagHiper test store",
"lang": "pt_br",
"currency": "BRL",
"currency_symbol": "R$"
}
Loading