Merge branch 'master' into feat/TRU-149-route-scopes
This commit is contained in:
@@ -0,0 +1,380 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace classes;
|
||||||
|
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use DateTimeZone;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Customer rule "auto-send invoice on the 3rd business day each month" (TRU-70 / DRIFT 9).
|
||||||
|
*
|
||||||
|
* The toggle lives in the existing `customer_attributes` table under the
|
||||||
|
* `autoSendInvoiceThirdBusinessDay` attribute. A daily cron task delegates to
|
||||||
|
* {@see autoSendInvoicesThirdBusinessDay()} which is a no-op on every day
|
||||||
|
* except the 3rd business day of the month, where it auto-queues ready
|
||||||
|
* collected invoices for the opted-in customers.
|
||||||
|
*
|
||||||
|
* "Business day" is computed against a locale-aware weekend (default
|
||||||
|
* Saturday + Sunday). Public Danish holidays are supported via a small
|
||||||
|
* override hook so the unit tests can pin the result without depending on
|
||||||
|
* the wall clock.
|
||||||
|
*/
|
||||||
|
class auto_send_invoice_third_business_day_service
|
||||||
|
{
|
||||||
|
public const ATTRIBUTE = 'autoSendInvoiceThirdBusinessDay';
|
||||||
|
|
||||||
|
public const DEFAULT_WEEKEND_DAYS = [6, 7]; // ISO-8601: 6 = Saturday, 7 = Sunday
|
||||||
|
|
||||||
|
/** @var list<string>|null */
|
||||||
|
private ?array $holidayCache = null;
|
||||||
|
|
||||||
|
/** @var callable|null */
|
||||||
|
private $holidayProviderOverride = null;
|
||||||
|
|
||||||
|
/** @var callable|null */
|
||||||
|
private $nowProviderOverride = null;
|
||||||
|
|
||||||
|
/** @var callable|null */
|
||||||
|
private $timeZoneProviderOverride = null;
|
||||||
|
|
||||||
|
public function isThirdBusinessDay(?DateTimeImmutable $date = null): bool
|
||||||
|
{
|
||||||
|
$timezone = $this->resolveTimezone();
|
||||||
|
$reference = $date ?? $this->resolveNow()->setTimezone($timezone);
|
||||||
|
$reference = $reference->setTimezone($timezone)->setTime(0, 0, 0);
|
||||||
|
|
||||||
|
$businessDay = 0;
|
||||||
|
$cursor = $reference->setDate(
|
||||||
|
(int)$reference->format('Y'),
|
||||||
|
(int)$reference->format('n'),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
$today = $reference;
|
||||||
|
|
||||||
|
while ($cursor <= $today) {
|
||||||
|
if ($this->isBusinessDay($cursor)) {
|
||||||
|
$businessDay++;
|
||||||
|
if ($businessDay === 3) {
|
||||||
|
return $cursor->format('Y-m-d') === $today->format('Y-m-d');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$cursor = $cursor->modify('+1 day');
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function thirdBusinessDayOfMonth(int $year, int $month): DateTimeImmutable
|
||||||
|
{
|
||||||
|
if ($year < 1970 || $year > 9999) {
|
||||||
|
throw new InvalidArgumentException("Year must be between 1970 and 9999, got {$year}");
|
||||||
|
}
|
||||||
|
if ($month < 1 || $month > 12) {
|
||||||
|
throw new InvalidArgumentException("Month must be between 1 and 12, got {$month}");
|
||||||
|
}
|
||||||
|
|
||||||
|
$timezone = $this->resolveTimezone();
|
||||||
|
$cursor = (new DateTimeImmutable(sprintf('%04d-%02d-01 00:00:00', $year, $month), $timezone))
|
||||||
|
->setTimezone($timezone);
|
||||||
|
$businessDay = 0;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
if ($this->isBusinessDay($cursor)) {
|
||||||
|
$businessDay++;
|
||||||
|
if ($businessDay === 3) {
|
||||||
|
return $cursor->setTime(0, 0, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$cursor = $cursor->modify('+1 day');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isBusinessDay(DateTimeImmutable $date): bool
|
||||||
|
{
|
||||||
|
$weekday = (int)$date->format('N');
|
||||||
|
if (in_array($weekday, self::DEFAULT_WEEKEND_DAYS, true)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$holidayKey = $date->format('Y-m-d');
|
||||||
|
foreach ($this->resolveHolidays() as $holiday) {
|
||||||
|
if ($holiday === $holidayKey) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-queue every ready collected invoice for customers with the
|
||||||
|
* `autoSendInvoiceThirdBusinessDay` attribute. Returns a summary of
|
||||||
|
* what was queued (or an empty `scanned` count when invoked on a
|
||||||
|
* non-trigger day).
|
||||||
|
*
|
||||||
|
* @return array{
|
||||||
|
* triggered: bool,
|
||||||
|
* trigger_date: ?string,
|
||||||
|
* customers: int,
|
||||||
|
* collections_scanned: int,
|
||||||
|
* jobs_enqueued: int,
|
||||||
|
* skipped_already_queued: int,
|
||||||
|
* errors: list<array{customer_number:int,collection_id:int,message:string}>
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public function runOnce(?DateTimeImmutable $now = null): array
|
||||||
|
{
|
||||||
|
$timezone = $this->resolveTimezone();
|
||||||
|
$today = ($now ?? $this->resolveNow())->setTimezone($timezone)->setTime(0, 0, 0);
|
||||||
|
|
||||||
|
$summary = [
|
||||||
|
'triggered' => false,
|
||||||
|
'trigger_date' => null,
|
||||||
|
'customers' => 0,
|
||||||
|
'collections_scanned' => 0,
|
||||||
|
'jobs_enqueued' => 0,
|
||||||
|
'skipped_already_queued' => 0,
|
||||||
|
'errors' => [],
|
||||||
|
];
|
||||||
|
|
||||||
|
if (!$this->isThirdBusinessDay($today)) {
|
||||||
|
return $summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
$summary['triggered'] = true;
|
||||||
|
$summary['trigger_date'] = $today->format('Y-m-d');
|
||||||
|
|
||||||
|
$customerNumbers = $this->loadEligibleCustomerNumbers();
|
||||||
|
$summary['customers'] = count($customerNumbers);
|
||||||
|
if ($customerNumbers === []) {
|
||||||
|
return $summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
$collections = $this->loadReadyInvoiceCollections($customerNumbers);
|
||||||
|
$summary['collections_scanned'] = count($collections);
|
||||||
|
if ($collections === []) {
|
||||||
|
return $summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
$queue = $this->createTransferQueue();
|
||||||
|
foreach ($collections as $collection) {
|
||||||
|
$collectionId = (int)($collection['id'] ?? 0);
|
||||||
|
if ($collectionId < 1) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$enqueued = $this->enqueueCollectionExport($queue, $collectionId);
|
||||||
|
} catch (Throwable $throwable) {
|
||||||
|
$summary['errors'][] = [
|
||||||
|
'customer_number' => (int)($collection['customer_number'] ?? 0),
|
||||||
|
'collection_id' => $collectionId,
|
||||||
|
'message' => $throwable->getMessage(),
|
||||||
|
];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ($enqueued === 'queued') {
|
||||||
|
$summary['jobs_enqueued']++;
|
||||||
|
} elseif ($enqueued === 'already_queued') {
|
||||||
|
$summary['skipped_already_queued']++;
|
||||||
|
}
|
||||||
|
// 'unavailable' is intentionally silent: the queue is optional
|
||||||
|
// and the next cron tick will pick up the collections.
|
||||||
|
}
|
||||||
|
|
||||||
|
return $summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setHolidayProviderOverride(callable $provider): void
|
||||||
|
{
|
||||||
|
$this->holidayProviderOverride = $provider;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setNowProviderOverride(callable $provider): void
|
||||||
|
{
|
||||||
|
$this->nowProviderOverride = $provider;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setTimeZoneProviderOverride(callable $provider): void
|
||||||
|
{
|
||||||
|
$this->timeZoneProviderOverride = $provider;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function clearOverrides(): void
|
||||||
|
{
|
||||||
|
$this->holidayProviderOverride = null;
|
||||||
|
$this->nowProviderOverride = null;
|
||||||
|
$this->timeZoneProviderOverride = null;
|
||||||
|
$this->holidayCache = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return list<int> */
|
||||||
|
public function loadEligibleCustomerNumbers(): array
|
||||||
|
{
|
||||||
|
global $db;
|
||||||
|
$attribute = $db->escape_string(self::ATTRIBUTE);
|
||||||
|
$result = $db->query(
|
||||||
|
"SELECT DISTINCT CAST(u.customer_number AS UNSIGNED) AS customer_number
|
||||||
|
FROM customer_attributes ca
|
||||||
|
INNER JOIN users u ON u.id = ca.user_id
|
||||||
|
WHERE ca.attribute = '{$attribute}'
|
||||||
|
AND u.customer_number IS NOT NULL
|
||||||
|
AND u.customer_number <> 0
|
||||||
|
AND u.deleted_at IS NULL
|
||||||
|
ORDER BY customer_number ASC"
|
||||||
|
);
|
||||||
|
if (!$result) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
$customerNumbers = [];
|
||||||
|
while ($row = $result->fetch_assoc()) {
|
||||||
|
$number = (int)($row['customer_number'] ?? 0);
|
||||||
|
if ($number > 0) {
|
||||||
|
$customerNumbers[] = $number;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $customerNumbers;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<int> $customerNumbers
|
||||||
|
* @return list<array<string,mixed>>
|
||||||
|
*/
|
||||||
|
public function loadReadyInvoiceCollections(array $customerNumbers): array
|
||||||
|
{
|
||||||
|
$customerNumbers = array_values(array_filter(array_map('intval', $customerNumbers), static fn(int $n): bool => $n > 0));
|
||||||
|
if ($customerNumbers === []) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
global $db;
|
||||||
|
$in = implode(',', $customerNumbers);
|
||||||
|
|
||||||
|
// A collection is "ready" when:
|
||||||
|
// - it belongs to one of the opted-in customers
|
||||||
|
// - it has at least one order linked to it
|
||||||
|
// - it has not been booked yet (no booked_at)
|
||||||
|
// - it has not been closed yet (no closed_at) — closures are reserved
|
||||||
|
// for already-booked/manual-approval flows
|
||||||
|
// - it has not been deleted
|
||||||
|
$result = $db->query(
|
||||||
|
"SELECT c.id, c.customer_number
|
||||||
|
FROM collected_order_invoices c
|
||||||
|
WHERE c.customer_number IN ({$in})
|
||||||
|
AND c.deleted_at IS NULL
|
||||||
|
AND (c.booked_at IS NULL OR c.booked_at = '0000-00-00 00:00:00')
|
||||||
|
AND (c.closed_at IS NULL OR c.closed_at = '0000-00-00 00:00:00')
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1 FROM orders o
|
||||||
|
WHERE o.invoice_collection_id = c.id
|
||||||
|
AND o.deleted_at IS NULL
|
||||||
|
)
|
||||||
|
ORDER BY c.customer_number ASC, c.id ASC"
|
||||||
|
);
|
||||||
|
if (!$result) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
$rows = [];
|
||||||
|
while ($row = $result->fetch_assoc()) {
|
||||||
|
$rows[] = [
|
||||||
|
'id' => (int)($row['id'] ?? 0),
|
||||||
|
'customer_number' => (int)($row['customer_number'] ?? 0),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return $rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for unit tests: when the production economic_transfer_queue is
|
||||||
|
* not available the cron task should still succeed with no jobs.
|
||||||
|
*/
|
||||||
|
protected function createTransferQueue(): ?economic_transfer_queue
|
||||||
|
{
|
||||||
|
if (!class_exists(economic_transfer_queue::class)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return new economic_transfer_queue();
|
||||||
|
} catch (Throwable) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return 'queued'|'already_queued'|'unavailable'
|
||||||
|
*/
|
||||||
|
private function enqueueCollectionExport(?economic_transfer_queue $queue, int $collectionId): string
|
||||||
|
{
|
||||||
|
if ($queue === null) {
|
||||||
|
return 'unavailable';
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$job = $queue->enqueue(
|
||||||
|
economic_transfer_queue::TYPE_COLLECTED_INVOICE_EXPORT,
|
||||||
|
[
|
||||||
|
'collected_invoice_id' => $collectionId,
|
||||||
|
'send_as_is' => false,
|
||||||
|
'requested_by' => 0,
|
||||||
|
'auto_send_third_business_day' => true,
|
||||||
|
],
|
||||||
|
0
|
||||||
|
);
|
||||||
|
} catch (Throwable $throwable) {
|
||||||
|
// The queue is designed to refuse duplicate enqueue by
|
||||||
|
// surfacing a domain-specific message — treat that as
|
||||||
|
// "already_queued" rather than a hard failure.
|
||||||
|
if (str_contains(strtolower($throwable->getMessage()), 'already')) {
|
||||||
|
return 'already_queued';
|
||||||
|
}
|
||||||
|
throw $throwable;
|
||||||
|
}
|
||||||
|
|
||||||
|
return is_array($job) && !empty($job['id']) ? 'queued' : 'already_queued';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return list<string> */
|
||||||
|
private function resolveHolidays(): array
|
||||||
|
{
|
||||||
|
if ($this->holidayCache !== null) {
|
||||||
|
return $this->holidayCache;
|
||||||
|
}
|
||||||
|
if ($this->holidayProviderOverride !== null) {
|
||||||
|
$value = ($this->holidayProviderOverride)();
|
||||||
|
$holidays = is_array($value) ? array_values(array_filter(array_map('strval', $value))) : [];
|
||||||
|
$this->holidayCache = $holidays;
|
||||||
|
return $holidays;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default: no hard-coded public holidays. Production can plug a
|
||||||
|
// concrete provider through setHolidayProviderOverride() once the
|
||||||
|
// holiday calendar is finalised. The Saturday/Sunday weekend
|
||||||
|
// logic is sufficient for the 3rd-business-day computation as
|
||||||
|
// long as the cron task runs on every weekday.
|
||||||
|
$this->holidayCache = [];
|
||||||
|
return $this->holidayCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveNow(): DateTimeImmutable
|
||||||
|
{
|
||||||
|
if ($this->nowProviderOverride !== null) {
|
||||||
|
$value = ($this->nowProviderOverride)();
|
||||||
|
if ($value instanceof DateTimeImmutable) {
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new DateTimeImmutable('now');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveTimezone(): DateTimeZone
|
||||||
|
{
|
||||||
|
if ($this->timeZoneProviderOverride !== null) {
|
||||||
|
$value = ($this->timeZoneProviderOverride)();
|
||||||
|
if ($value instanceof DateTimeZone) {
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
if (is_string($value) && $value !== '') {
|
||||||
|
return new DateTimeZone($value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new DateTimeZone('Europe/Copenhagen');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -54,6 +54,7 @@ class customer_rule_product_restriction_service
|
|||||||
'showPricesOnBookingPage',
|
'showPricesOnBookingPage',
|
||||||
'usePONumbers',
|
'usePONumbers',
|
||||||
'exemptFromAdministrationFee',
|
'exemptFromAdministrationFee',
|
||||||
|
'autoSendInvoiceThirdBusinessDay',
|
||||||
];
|
];
|
||||||
|
|
||||||
public function __construct()
|
public function __construct()
|
||||||
|
|||||||
@@ -689,6 +689,38 @@ function EconomicTransferQueueCron(): void
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Customer rule "auto-send invoice on the 3rd business day each month"
|
||||||
|
* (TRU-70 / DRIFT 9).
|
||||||
|
*
|
||||||
|
* The handler is a no-op on every day except the 3rd business day of
|
||||||
|
* the current month (Europe/Copenhagen timezone). On that day it scans
|
||||||
|
* the customers with the `autoSendInvoiceThirdBusinessDay` attribute
|
||||||
|
* and enqueues every ready collected invoice for export via the
|
||||||
|
* existing `economic_transfer_queue` machinery. The actual e-conomic
|
||||||
|
* send is handled asynchronously by `EconomicTransferQueueCron`.
|
||||||
|
*/
|
||||||
|
function AutoSendInvoicesThirdBusinessDay(): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$service = new \classes\auto_send_invoice_third_business_day_service();
|
||||||
|
$summary = $service->runOnce();
|
||||||
|
if (!empty($summary['triggered'])) {
|
||||||
|
echo "[" . date('Y-m-d H:i:s') . "][CRON] AutoSendInvoicesThirdBusinessDay trigger_date="
|
||||||
|
. ($summary['trigger_date'] ?? '')
|
||||||
|
. " customers=" . (int)($summary['customers'] ?? 0)
|
||||||
|
. " collections=" . (int)($summary['collections_scanned'] ?? 0)
|
||||||
|
. " enqueued=" . (int)($summary['jobs_enqueued'] ?? 0)
|
||||||
|
. " already_queued=" . (int)($summary['skipped_already_queued'] ?? 0)
|
||||||
|
. " errors=" . count($summary['errors'] ?? [])
|
||||||
|
. "\n";
|
||||||
|
}
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
warn('AutoSendInvoicesThirdBusinessDay failed: ' . $e->getMessage());
|
||||||
|
error_log('[cron-auto-send-third-business-day] failed: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function PruneSystemSessionActivityCron(): void
|
function PruneSystemSessionActivityCron(): void
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -61,4 +61,16 @@ return [
|
|||||||
'estimated_duration_ms' => 3000,
|
'estimated_duration_ms' => 3000,
|
||||||
'priority' => 20,
|
'priority' => 20,
|
||||||
],
|
],
|
||||||
|
[
|
||||||
|
'id' => 'economic.auto_send_invoices_third_business_day',
|
||||||
|
'legacy_name' => 'AutoSendInvoicesThirdBusinessDay',
|
||||||
|
'name' => 'Auto-send invoices on the 3rd business day each month',
|
||||||
|
'description' => 'For customers with the autoSendInvoiceThirdBusinessDay attribute, enqueue every ready collected invoice for export on the 3rd business day of the month. The handler is a no-op on every other day.',
|
||||||
|
'module' => 'economic',
|
||||||
|
'handler' => 'AutoSendInvoicesThirdBusinessDay',
|
||||||
|
'schedule' => ['type' => 'interval', 'seconds' => 86400],
|
||||||
|
'timeout_seconds' => 900,
|
||||||
|
'estimated_duration_ms' => 5000,
|
||||||
|
'priority' => 25,
|
||||||
|
],
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -10344,8 +10344,11 @@ paths:
|
|||||||
post:
|
post:
|
||||||
tags:
|
tags:
|
||||||
- Modules
|
- Modules
|
||||||
summary: Create Stripe invoice
|
summary: Create Stripe invoice (retired - TRU-74 / DRIFT 13)
|
||||||
description: Create an invoice in Stripe
|
description: |
|
||||||
|
Retired in favour of in-store card payments. Always returns HTTP 410
|
||||||
|
with `code: stripe_email_payment_disabled` so the POS can fall back
|
||||||
|
to the standard card-payment flow.
|
||||||
operationId: createStripeInvoice
|
operationId: createStripeInvoice
|
||||||
requestBody:
|
requestBody:
|
||||||
required: false
|
required: false
|
||||||
@@ -10353,11 +10356,41 @@ paths:
|
|||||||
application/json:
|
application/json:
|
||||||
schema: {}
|
schema: {}
|
||||||
responses:
|
responses:
|
||||||
'201':
|
'410':
|
||||||
description: Stripe invoice created successfully
|
description: Direct Stripe payment links by email are no longer available
|
||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema: {}
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
code:
|
||||||
|
type: string
|
||||||
|
example: stripe_email_payment_disabled
|
||||||
|
message:
|
||||||
|
type: string
|
||||||
|
delete:
|
||||||
|
tags:
|
||||||
|
- Modules
|
||||||
|
summary: Cancel/clean up a legacy Stripe hosted invoice
|
||||||
|
description: |
|
||||||
|
Void a pre-existing Stripe hosted invoice that was created before
|
||||||
|
direct payment links were retired from POS (TRU-74 / DRIFT 13).
|
||||||
|
Card payments created via the new flow are not affected and use
|
||||||
|
the standard payment-intent lifecycle instead.
|
||||||
|
operationId: cancelLegacyStripeInvoice
|
||||||
|
parameters:
|
||||||
|
- name: order_id
|
||||||
|
in: query
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Legacy Stripe hosted invoice was voided
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
|
||||||
/modules/stripe/terminal/readers:
|
/modules/stripe/terminal/readers:
|
||||||
get:
|
get:
|
||||||
|
|||||||
@@ -0,0 +1,221 @@
|
|||||||
|
<?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');
|
||||||
|
});
|
||||||
@@ -7,12 +7,13 @@ it('discovers module-owned cron task definitions', function (): void {
|
|||||||
$registry = new cron_task_registry(app_path('modules'));
|
$registry = new cron_task_registry(app_path('modules'));
|
||||||
$definitions = $registry->definitions();
|
$definitions = $registry->definitions();
|
||||||
|
|
||||||
expect($definitions)->toHaveCount(22);
|
expect($definitions)->toHaveCount(23);
|
||||||
expect(array_keys($definitions))->toContain(
|
expect(array_keys($definitions))->toContain(
|
||||||
'system.sync_logs',
|
'system.sync_logs',
|
||||||
'backups.process_jobs',
|
'backups.process_jobs',
|
||||||
'backups.prune_retention',
|
'backups.prune_retention',
|
||||||
'economic.transfer_queue',
|
'economic.transfer_queue',
|
||||||
|
'economic.auto_send_invoices_third_business_day',
|
||||||
'dynamicimages.pre_render',
|
'dynamicimages.pre_render',
|
||||||
'weatherapi.preload_department_responses',
|
'weatherapi.preload_department_responses',
|
||||||
'goals.progress_alerts',
|
'goals.progress_alerts',
|
||||||
@@ -26,6 +27,12 @@ it('discovers module-owned cron task definitions', function (): void {
|
|||||||
expect($transferQueue->id)->toBe('economic.transfer_queue');
|
expect($transferQueue->id)->toBe('economic.transfer_queue');
|
||||||
expect($transferQueue->module)->toBe('economic');
|
expect($transferQueue->module)->toBe('economic');
|
||||||
expect($transferQueue->schedule)->toBe(['type' => 'interval', 'seconds' => 30]);
|
expect($transferQueue->schedule)->toBe(['type' => 'interval', 'seconds' => 30]);
|
||||||
|
|
||||||
|
$autoSend = $registry->get('AutoSendInvoicesThirdBusinessDay');
|
||||||
|
expect($autoSend)->not->toBeNull();
|
||||||
|
expect($autoSend->id)->toBe('economic.auto_send_invoices_third_business_day');
|
||||||
|
expect($autoSend->module)->toBe('economic');
|
||||||
|
expect($autoSend->schedule)->toBe(['type' => 'interval', 'seconds' => 86400]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps every discovered cron task in a module cron folder', function (): void {
|
it('keeps every discovered cron task in a module cron folder', function (): void {
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
it('documents the retired Stripe hosted invoice creation route in the public OpenAPI spec (TRU-74)', function (): void {
|
||||||
|
$openApiFile = dirname(__DIR__, 3) . '/openapi.yaml';
|
||||||
|
$contents = file_get_contents($openApiFile);
|
||||||
|
|
||||||
|
expect($contents)->not->toBeFalse();
|
||||||
|
|
||||||
|
$needle = " /modules/stripe/invoice:";
|
||||||
|
$start = strpos($contents, $needle);
|
||||||
|
expect($start)->not->toBeFalse();
|
||||||
|
|
||||||
|
$nextPathStart = strpos($contents, "\n /", $start + strlen($needle));
|
||||||
|
if ($nextPathStart === false) {
|
||||||
|
$nextPathStart = strlen($contents);
|
||||||
|
}
|
||||||
|
|
||||||
|
$block = substr($contents, $start, $nextPathStart - $start);
|
||||||
|
// Normalise trailing whitespace so the assertion is stable across editors.
|
||||||
|
$normalised = preg_replace('/[ \t]+$/m', '', $block);
|
||||||
|
|
||||||
|
expect($normalised)->toContain(" /modules/stripe/invoice:");
|
||||||
|
expect($normalised)->toContain('summary: Create Stripe invoice (retired - TRU-74 / DRIFT 13)');
|
||||||
|
expect($normalised)->toContain("'410':");
|
||||||
|
expect($normalised)->toContain('stripe_email_payment_disabled');
|
||||||
|
expect($normalised)->toContain('Cancel/clean up a legacy Stripe hosted invoice');
|
||||||
|
expect($normalised)->toContain('cancelLegacyStripeInvoice');
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user