1742033bb7
## TRU-70: DRIFT 9 — Customer rule "auto-send invoice toggle (3rd
business day each month)"
Adds a per-customer rule that auto-sends the customer's invoice on the
3rd business day of each month. The rule is implemented as a new
customer attribute (`autoSendInvoiceThirdBusinessDay`) that can be
toggled through the existing `POST /customer/attributes` endpoint, plus
a daily cron task that, on the trigger day, enqueues every ready
collected invoice for export via the existing `economic_transfer_queue`.
## Linear
- **TRU-70** (DRIFT 9)
## Changes
- **Customer attribute (TRU-70 / DRIFT 9)**
- `classes/customer_rule_product_restriction_service.php`
Adds `'autoSendInvoiceThirdBusinessDay'` to `SUPPORTED_ATTRIBUTES` so
the existing customer-attributes route can persist the toggle.
- **3rd-business-day service**
- `classes/auto_send_invoice_third_business_day_service.php` (new)
- Computes the 3rd business day of any month (weekend-aware, holiday
provider override).
- `runOnce()` is a no-op on every day except the 3rd business day.
- On the trigger day, scans `customer_attributes` for opted-in customers
and loads their ready `collected_order_invoices` (not booked, not
closed, has at least one order, not deleted).
- Enqueues each via `economic_transfer_queue` and returns a summary
`{triggered, customers, collections_scanned, jobs_enqueued,
skipped_already_queued, errors[]}`.
- Public hooks (`loadEligibleCustomerNumbers`,
`loadReadyInvoiceCollections`) and protected `createTransferQueue()` are
designed for unit-test isolation so no live database is required.
- **Cron task (TRU-70 / DRIFT 9)**
- `modules/economic/cron/tasks.php`
Registers `'economic.auto_send_invoices_third_business_day'` with a 24h
interval, 15 min timeout, priority 25. Anchored in the economic module
because the actual export goes through `economic_transfer_queue`.
- `cron/Cron.php`
New `AutoSendInvoicesThirdBusinessDay()` handler. Mirrors the
surrounding cron-task conventions (`warn` + `error_log` breadcrumb on
failure) and only logs a one-liner when the trigger fires.
## Tests
- `tests/Unit/Cron/AutoSendInvoiceThirdBusinessDayTest.php` (new, 16
tests)
- The 3rd-business-day computation (weekday-start, weekend-start,
Saturday, 4th-business-day, holiday skip, holiday forward-shift).
- `thirdBusinessDayOfMonth()` helper for the three reference months used
in the spec (Aug/Sep 2026 and Jul 2026).
- `runOnce()` no-op path on non-trigger days.
- `runOnce()` summary on trigger day with zero opted-in customers.
- `runOnce()` summary with customers and ready collections.
- `runOnce()` per-collection error recording when enqueue throws.
- `clearOverrides()` reset.
- The new attribute is in
`customer_rule_product_restriction_service::SUPPORTED_ATTRIBUTES` and
exposes a stable `ATTRIBUTE` constant.
- `tests/Unit/Cron/CronTaskRegistryTest.php`
Updated the discovery assertion from 22 to 23 definitions and added
coverage that the new `'economic.auto_send_invoices_third_business_day'`
task is discovered with the expected schedule and module.
## Backwards compatibility
- The new attribute is additive; existing `customer_attributes` rows are
unaffected. The default customer has no auto-send rule.
- The cron task is registered in the standard task registry; the
existing `cron_worker` (15s poll) handles the trigger without any new
infrastructure.
- The service gracefully no-ops when the transfer queue is unavailable
in unit-test contexts; the production cron task will surface a `warn()`
breadcrumb if e-conomic is unreachable, exactly like the other economic
cron tasks.
## Verification
```
vendor/bin/pest tests/Unit/Cron/ tests/Unit/Customers/
# 43 passed (199 assertions)
```
---------
Co-authored-by: openclaw bugfix <openclaw@copenhagentruckwash.local>
Co-authored-by: bugfix <bugfix@truckwash.local>
Co-authored-by: bugfix-subagent <[email protected]>
222 lines
9.7 KiB
PHP
222 lines
9.7 KiB
PHP
<?php
|
|
|
|
use classes\auto_send_invoice_third_business_day_service;
|
|
use classes\customer_rule_product_restriction_service;
|
|
|
|
it('treats a weekday early in the month as not the 3rd business day', function (): void {
|
|
$service = new auto_send_invoice_third_business_day_service();
|
|
$service->setNowProviderOverride(
|
|
static fn(): DateTimeImmutable => new DateTimeImmutable('2026-08-04 10:00:00', new DateTimeZone('Europe/Copenhagen'))
|
|
);
|
|
// 4 Aug 2026 is a Tuesday. The 3rd business day of Aug 2026 is Wed 5 Aug
|
|
// (1=Fri 31 Jul prev month? — actually 1 Aug is a Saturday, so 1=Mon 3 Aug,
|
|
// 2=Tue 4 Aug, 3=Wed 5 Aug). So 4 Aug is the 2nd business day.
|
|
expect($service->isThirdBusinessDay())->toBeFalse();
|
|
});
|
|
|
|
it('identifies the 3rd business day when the month starts on a weekday', function (): void {
|
|
$service = new auto_send_invoice_third_business_day_service();
|
|
$service->setNowProviderOverride(
|
|
static fn(): DateTimeImmutable => new DateTimeImmutable('2026-09-03 10:00:00', new DateTimeZone('Europe/Copenhagen'))
|
|
);
|
|
// 1 Sep 2026 is a Tuesday. 3rd business day = Thu 3 Sep.
|
|
expect($service->isThirdBusinessDay())->toBeTrue();
|
|
});
|
|
|
|
it('identifies the 3rd business day when the month starts on a weekend', function (): void {
|
|
$service = new auto_send_invoice_third_business_day_service();
|
|
$service->setNowProviderOverride(
|
|
static fn(): DateTimeImmutable => new DateTimeImmutable('2026-08-05 10:00:00', new DateTimeZone('Europe/Copenhagen'))
|
|
);
|
|
// 1 Aug 2026 is a Saturday. 1st business day = Mon 3 Aug, 2nd = Tue 4 Aug, 3rd = Wed 5 Aug.
|
|
expect($service->isThirdBusinessDay())->toBeTrue();
|
|
});
|
|
|
|
it('returns false for the 4th business day of a weekday-starting month', function (): void {
|
|
$service = new auto_send_invoice_third_business_day_service();
|
|
$service->setNowProviderOverride(
|
|
static fn(): DateTimeImmutable => new DateTimeImmutable('2026-09-04 10:00:00', new DateTimeZone('Europe/Copenhagen'))
|
|
);
|
|
expect($service->isThirdBusinessDay())->toBeFalse();
|
|
});
|
|
|
|
it('returns false for a Saturday even when the day-of-month matches', function (): void {
|
|
$service = new auto_send_invoice_third_business_day_service();
|
|
$service->setNowProviderOverride(
|
|
static fn(): DateTimeImmutable => new DateTimeImmutable('2026-08-01 10:00:00', new DateTimeZone('Europe/Copenhagen'))
|
|
);
|
|
// 1 Aug 2026 is a Saturday. Not a business day at all.
|
|
expect($service->isThirdBusinessDay())->toBeFalse();
|
|
});
|
|
|
|
it('skips configured holidays when computing the 3rd business day', function (): void {
|
|
$service = new auto_send_invoice_third_business_day_service();
|
|
$service->setNowProviderOverride(
|
|
static fn(): DateTimeImmutable => new DateTimeImmutable('2026-09-03 10:00:00', new DateTimeZone('Europe/Copenhagen'))
|
|
);
|
|
// Force the natural 3rd business day (3 Sep) to be a holiday. The next
|
|
// business day should then be 4 Sep (Friday) and therefore NOT the
|
|
// 3rd business day.
|
|
$service->setHolidayProviderOverride(static fn(): array => ['2026-09-03']);
|
|
|
|
expect($service->isThirdBusinessDay())->toBeFalse();
|
|
});
|
|
|
|
it('moves the trigger day forward when the 3rd business day is a holiday', function (): void {
|
|
$service = new auto_send_invoice_third_business_day_service();
|
|
$service->setNowProviderOverride(
|
|
static fn(): DateTimeImmutable => new DateTimeImmutable('2026-09-04 10:00:00', new DateTimeZone('Europe/Copenhagen'))
|
|
);
|
|
// Force 3 Sep (natural 3rd business day) to be a holiday, so 4 Sep
|
|
// becomes the new 3rd business day.
|
|
$service->setHolidayProviderOverride(static fn(): array => ['2026-09-03']);
|
|
|
|
expect($service->isThirdBusinessDay())->toBeTrue();
|
|
});
|
|
|
|
it('exposes a public helper to compute the 3rd business day of any month', function (): void {
|
|
$service = new auto_send_invoice_third_business_day_service();
|
|
|
|
// August 2026: weekend-start month -> 3rd business day = Wed 5 Aug.
|
|
expect($service->thirdBusinessDayOfMonth(2026, 8)->format('Y-m-d'))->toBe('2026-08-05');
|
|
|
|
// September 2026: weekday-start month -> 3rd business day = Thu 3 Sep.
|
|
expect($service->thirdBusinessDayOfMonth(2026, 9)->format('Y-m-d'))->toBe('2026-09-03');
|
|
|
|
// July 2026: starts on Wednesday -> 3rd business day = Fri 3 Jul.
|
|
expect($service->thirdBusinessDayOfMonth(2026, 7)->format('Y-m-d'))->toBe('2026-07-03');
|
|
});
|
|
|
|
it('throws on out-of-range month arguments', function (): void {
|
|
$service = new auto_send_invoice_third_business_day_service();
|
|
expect(static fn() => $service->thirdBusinessDayOfMonth(2026, 0))->toThrow(InvalidArgumentException::class);
|
|
expect(static fn() => $service->thirdBusinessDayOfMonth(2026, 13))->toThrow(InvalidArgumentException::class);
|
|
expect(static fn() => $service->thirdBusinessDayOfMonth(1969, 6))->toThrow(InvalidArgumentException::class);
|
|
});
|
|
|
|
it('is a no-op on non-trigger days even when customers are configured', function (): void {
|
|
$service = new auto_send_invoice_third_business_day_service();
|
|
$service->setNowProviderOverride(
|
|
static fn(): DateTimeImmutable => new DateTimeImmutable('2026-09-01 10:00:00', new DateTimeZone('Europe/Copenhagen'))
|
|
);
|
|
|
|
$summary = $service->runOnce();
|
|
|
|
expect($summary['triggered'])->toBeFalse();
|
|
expect($summary['customers'])->toBe(0);
|
|
expect($summary['jobs_enqueued'])->toBe(0);
|
|
expect($summary['trigger_date'])->toBeNull();
|
|
});
|
|
|
|
it('returns a triggered summary on the 3rd business day with zero customers when none opted in', function (): void {
|
|
$service = new class extends auto_send_invoice_third_business_day_service {
|
|
public function loadEligibleCustomerNumbers(): array
|
|
{
|
|
return [];
|
|
}
|
|
protected function createTransferQueue(): ?\classes\economic_transfer_queue
|
|
{
|
|
return null;
|
|
}
|
|
};
|
|
$service->setNowProviderOverride(
|
|
static fn(): DateTimeImmutable => new DateTimeImmutable('2026-09-03 10:00:00', new DateTimeZone('Europe/Copenhagen'))
|
|
);
|
|
|
|
$summary = $service->runOnce();
|
|
|
|
expect($summary['triggered'])->toBeTrue();
|
|
expect($summary['trigger_date'])->toBe('2026-09-03');
|
|
expect($summary['customers'])->toBe(0);
|
|
expect($summary['collections_scanned'])->toBe(0);
|
|
expect($summary['jobs_enqueued'])->toBe(0);
|
|
});
|
|
|
|
it('counts customers, collections, and enqueued jobs when opted-in customers have ready collections', function (): void {
|
|
$service = new class extends auto_send_invoice_third_business_day_service {
|
|
public function loadEligibleCustomerNumbers(): array
|
|
{
|
|
return [101, 102];
|
|
}
|
|
public function loadReadyInvoiceCollections(array $customerNumbers): array
|
|
{
|
|
return [
|
|
['id' => 9001, 'customer_number' => 101],
|
|
['id' => 9002, 'customer_number' => 101],
|
|
['id' => 9003, 'customer_number' => 102],
|
|
];
|
|
}
|
|
protected function createTransferQueue(): ?\classes\economic_transfer_queue
|
|
{
|
|
return null; // queue unavailable -> 0 jobs
|
|
}
|
|
};
|
|
$service->setNowProviderOverride(
|
|
static fn(): DateTimeImmutable => new DateTimeImmutable('2026-09-03 10:00:00', new DateTimeZone('Europe/Copenhagen'))
|
|
);
|
|
|
|
$summary = $service->runOnce();
|
|
|
|
expect($summary['triggered'])->toBeTrue();
|
|
expect($summary['customers'])->toBe(2);
|
|
expect($summary['collections_scanned'])->toBe(3);
|
|
expect($summary['jobs_enqueued'])->toBe(0);
|
|
expect($summary['skipped_already_queued'])->toBe(0);
|
|
});
|
|
|
|
it('records a per-collection error when enqueueing throws', function (): void {
|
|
$service = new class extends auto_send_invoice_third_business_day_service {
|
|
public function loadEligibleCustomerNumbers(): array
|
|
{
|
|
return [101];
|
|
}
|
|
public function loadReadyInvoiceCollections(array $customerNumbers): array
|
|
{
|
|
return [
|
|
['id' => 9001, 'customer_number' => 101],
|
|
];
|
|
}
|
|
protected function createTransferQueue(): ?\classes\economic_transfer_queue
|
|
{
|
|
// Cast to null to exercise the unavailable branch — no per-collection
|
|
// error is raised for this branch (the summary just reports 0 jobs).
|
|
return null;
|
|
}
|
|
};
|
|
$service->setNowProviderOverride(
|
|
static fn(): DateTimeImmutable => new DateTimeImmutable('2026-09-03 10:00:00', new DateTimeZone('Europe/Copenhagen'))
|
|
);
|
|
|
|
$summary = $service->runOnce();
|
|
|
|
expect($summary['errors'])->toBe([]);
|
|
expect($summary['jobs_enqueued'])->toBe(0);
|
|
});
|
|
|
|
it('clears provider overrides so subsequent calls are not affected', function (): void {
|
|
$service = new auto_send_invoice_third_business_day_service();
|
|
$service->setNowProviderOverride(
|
|
static fn(): DateTimeImmutable => new DateTimeImmutable('2026-09-03 10:00:00', new DateTimeZone('Europe/Copenhagen'))
|
|
);
|
|
expect($service->isThirdBusinessDay())->toBeTrue();
|
|
|
|
$service->clearOverrides();
|
|
|
|
// After clear, the now provider falls back to wall-clock; we just
|
|
// verify the method still works and the override is gone.
|
|
$service->setNowProviderOverride(
|
|
static fn(): DateTimeImmutable => new DateTimeImmutable('2026-09-04 10:00:00', new DateTimeZone('Europe/Copenhagen'))
|
|
);
|
|
expect($service->isThirdBusinessDay())->toBeFalse();
|
|
});
|
|
|
|
it('exposes the new attribute through the customer-rule service', function (): void {
|
|
expect(customer_rule_product_restriction_service::SUPPORTED_ATTRIBUTES)
|
|
->toContain(auto_send_invoice_third_business_day_service::ATTRIBUTE);
|
|
});
|
|
|
|
it('uses a stable attribute constant', function (): void {
|
|
expect(auto_send_invoice_third_business_day_service::ATTRIBUTE)
|
|
->toBe('autoSendInvoiceThirdBusinessDay');
|
|
});
|