## Summary - Makes Stripe Terminal card payment intents always use 25% moms in the API, independent of any client-supplied `tax_percentage`. - Updates amount calculation, metadata persistence, stored-intent reuse matching, the authoritative OpenAPI contracts, and operation-specific Writerside outputs. - Prevents double charging and false order closure across stale, concurrently succeeded, partially recorded, or mismatched intents. - Serializes payment create/capture/closure with order-item changes and every order-to-invoice-collection reassignment through shared database locks. - Converts expected lock contention and reconciliation cases into deliberate 409 responses. ## Exact-head evidence Current head: `3a0f70d315a94d2efe586a2188d2c54f8ff11cd4` - PHP syntax passed for all changed runtime files. - Focused Orders suite: **42 tests / 293 assertions passed**. - `git diff --check` passed. - Fresh exact-head Tests and Qodana are running. - Every Codex finding has a concrete reply; a fresh exact-head review is requested below. ## Safety behavior - Caller-controlled VAT is absent from request contracts; fixed 25% moms is server-owned. - A succeeded payment is preserved, requires the full expected `amount_received`, and cannot close a changed/mismatched or already-claimed collection. - A compatible partially recorded Stripe closure is completed idempotently; conflicting partial state fails closed for manual reconciliation. - Every cancellation/delete caller honors a concurrent-success result and never falsely reports a completed payment as cleared. - Price changes and invoice-collection reassignment share the payment lock through validation, capture, post-capture reload, and closure. - Reader changes are persisted only for reusable matching intents, so stale intent cancellation targets the original terminal. - Accepted legacy succeeded intents normalize stored tax to 25% before response construction. --------- Co-authored-by: Jeppe Bundgaard <jb@truckwash.dk>
630 lines
25 KiB
PHP
630 lines
25 KiB
PHP
<?php
|
|
|
|
namespace classes;
|
|
|
|
use Exception;
|
|
use objects\collected_order_invoices_o;
|
|
use objects\logs_o;
|
|
use objects\order_items_o;
|
|
use objects\orders_o;
|
|
use objects\products_o;
|
|
use objects\users_o;
|
|
|
|
class invoice_collection_bulk_action_service
|
|
{
|
|
public const ACTION_CLEAN_CUSTOMER_RULES = 'remove_customer_rule_violations';
|
|
public const ACTION_MERGE = 'merge_collections';
|
|
public const ACTION_SPLIT_BY_MONTH = 'split_by_month';
|
|
public const ACTION_RESET_HIDDEN_PRICES = 'reset_hidden_item_prices';
|
|
public const ACTION_QUEUE_ECONOMIC = 'queue_economic';
|
|
|
|
private const PREVIEW_TTL_SECONDS = 600;
|
|
private const MAX_COLLECTIONS = 100;
|
|
private const CONFIRMATION_PHRASES = [
|
|
'da' => 'Bekræft',
|
|
'en' => 'Confirm',
|
|
'sv' => 'Bekräfta',
|
|
'no' => 'Bekreft',
|
|
'de' => 'Bestätigen',
|
|
];
|
|
|
|
public function preview(string $action, array $invoiceCollectionIds, array $options = [], string $locale = 'da'): array
|
|
{
|
|
$action = $this->normalizeAction($action);
|
|
$invoiceCollectionIds = $this->normalizeInvoiceCollectionIds($invoiceCollectionIds);
|
|
$options = $this->normalizeOptions($options);
|
|
|
|
$preview = $this->buildPreview($action, $invoiceCollectionIds, $options, $locale);
|
|
$previewId = $this->previewId();
|
|
$preview['preview_id'] = $previewId;
|
|
$preview['selection_hash'] = $this->selectionHash($action, $invoiceCollectionIds, $options);
|
|
$preview['confirmation_phrase'] = $this->confirmationPhrase($locale);
|
|
|
|
$this->cachePreview($previewId, [
|
|
'action' => $action,
|
|
'invoice_collection_ids' => $invoiceCollectionIds,
|
|
'options' => $options,
|
|
'locale' => $locale,
|
|
'selection_hash' => $preview['selection_hash'],
|
|
'preview' => $preview,
|
|
]);
|
|
|
|
return $preview;
|
|
}
|
|
|
|
public function apply(
|
|
string $previewId,
|
|
string $action,
|
|
array $invoiceCollectionIds,
|
|
array $options,
|
|
string $confirmationText,
|
|
int $actorUserId,
|
|
string $locale = 'da'
|
|
): array {
|
|
global $db;
|
|
|
|
$action = $this->normalizeAction($action);
|
|
$invoiceCollectionIds = $this->normalizeInvoiceCollectionIds($invoiceCollectionIds);
|
|
$options = $this->normalizeOptions($options);
|
|
$cached = $this->getCachedPreview($previewId);
|
|
$selectionHash = $this->selectionHash($action, $invoiceCollectionIds, $options);
|
|
|
|
if (!$cached || ($cached['selection_hash'] ?? '') !== $selectionHash) {
|
|
throw new Exception('Preview is missing, expired, or no longer matches the selected invoice collections.');
|
|
}
|
|
|
|
$expectedConfirmation = (string)($cached['preview']['confirmation_phrase'] ?? $this->confirmationPhrase($locale));
|
|
if (trim($confirmationText) !== $expectedConfirmation) {
|
|
throw new Exception('Confirmation text does not match.');
|
|
}
|
|
|
|
$freshPreview = $this->buildPreview($action, $invoiceCollectionIds, $options, (string)($cached['locale'] ?? $locale));
|
|
if (!empty($freshPreview['blockers'])) {
|
|
throw new Exception('Action cannot be applied while blockers are present.');
|
|
}
|
|
$lockedCollectionIds = [
|
|
...$invoiceCollectionIds,
|
|
(int)($options['target_invoice_collection_id'] ?? 0),
|
|
];
|
|
$paymentMutationLock = order_payment_lock::tryAcquireInvoiceCollections(
|
|
$lockedCollectionIds
|
|
);
|
|
if ($paymentMutationLock === null) {
|
|
throw new Exception(
|
|
'An invoice collection is currently being changed or paid. Try again.'
|
|
);
|
|
}
|
|
|
|
$db->conn()->begin_transaction();
|
|
try {
|
|
$result = match ($action) {
|
|
self::ACTION_CLEAN_CUSTOMER_RULES => $this->applyCleanCustomerRules($freshPreview),
|
|
self::ACTION_MERGE => $this->applyMerge($freshPreview, $options),
|
|
self::ACTION_SPLIT_BY_MONTH => $this->applySplitByMonth($freshPreview),
|
|
self::ACTION_RESET_HIDDEN_PRICES => $this->applyResetHiddenPrices($freshPreview),
|
|
self::ACTION_QUEUE_ECONOMIC => [
|
|
'queued_invoice_collection_ids' => $invoiceCollectionIds,
|
|
'changed_count' => count($invoiceCollectionIds),
|
|
],
|
|
default => throw new Exception('Unsupported action'),
|
|
};
|
|
(new logs_o())->add(
|
|
'orderInvoices',
|
|
'global',
|
|
1,
|
|
$actorUserId,
|
|
'APPLY_COLLECTED_INVOICE_BULK_ACTION',
|
|
'Applied collected invoice bulk action ' . $action . ' to ' . count($invoiceCollectionIds) . ' invoice collections'
|
|
);
|
|
$db->conn()->commit();
|
|
} catch (\Throwable $e) {
|
|
$db->conn()->rollback();
|
|
throw $e;
|
|
}
|
|
|
|
$this->deleteCachedPreview($previewId);
|
|
|
|
return [
|
|
...$freshPreview,
|
|
'preview' => false,
|
|
'result' => $result,
|
|
];
|
|
}
|
|
|
|
private function buildPreview(string $action, array $invoiceCollectionIds, array $options, string $locale): array
|
|
{
|
|
$collections = $this->loadCollections($invoiceCollectionIds);
|
|
$base = [
|
|
'action' => $action,
|
|
'preview' => true,
|
|
'confirmation_phrase' => $this->confirmationPhrase($locale),
|
|
'invoice_collection_ids' => $invoiceCollectionIds,
|
|
'collections' => array_map(fn(collected_order_invoices_o $collection): array => $this->collectionSummary($collection), $collections),
|
|
'warnings' => [],
|
|
'blockers' => [],
|
|
];
|
|
|
|
return match ($action) {
|
|
self::ACTION_CLEAN_CUSTOMER_RULES => $this->previewCleanCustomerRules($base, $collections),
|
|
self::ACTION_MERGE => $this->previewMerge($base, $collections, $options),
|
|
self::ACTION_SPLIT_BY_MONTH => $this->previewSplitByMonth($base, $collections),
|
|
self::ACTION_RESET_HIDDEN_PRICES => $this->previewResetHiddenPrices($base, $collections),
|
|
self::ACTION_QUEUE_ECONOMIC => $this->previewQueueEconomic($base, $collections),
|
|
default => throw new Exception('Unsupported action'),
|
|
};
|
|
}
|
|
|
|
private function previewCleanCustomerRules(array $preview, array $collections): array
|
|
{
|
|
$items = [];
|
|
$blockers = [];
|
|
foreach ($collections as $collection) {
|
|
$blockers = [...$blockers, ...$this->contentMutationBlockers($collection)];
|
|
$rows = $this->orderItemRows((int)$collection->id);
|
|
$violatingItemIds = [];
|
|
$includedItemIds = [];
|
|
foreach ($rows as $row) {
|
|
$violation = (new customer_product_rule_service())->firstViolationForOrderItem(
|
|
(int)$row['order_id'],
|
|
(int)$row['product_id'],
|
|
empty($row['related_item_id']) ? null : (int)$row['related_item_id']
|
|
);
|
|
if ($violation === null) {
|
|
continue;
|
|
}
|
|
$violatingItemIds[] = (int)$row['order_item_id'];
|
|
$includedItemIds[] = (int)$row['order_item_id'];
|
|
$items[] = [
|
|
'invoice_collection_id' => (int)$collection->id,
|
|
'order_id' => (int)$row['order_id'],
|
|
'order_item_id' => (int)$row['order_item_id'],
|
|
'product_id' => (int)$row['product_id'],
|
|
'product_name' => (string)$row['product_name'],
|
|
'rule' => (string)$violation['rule'],
|
|
'price' => (int)$row['price'],
|
|
'quantity' => (int)$row['quantity'],
|
|
'will_soft_delete' => true,
|
|
];
|
|
}
|
|
|
|
foreach ($rows as $row) {
|
|
$orderItemId = (int)$row['order_item_id'];
|
|
$relatedItemId = empty($row['related_item_id']) ? null : (int)$row['related_item_id'];
|
|
if ($relatedItemId === null || !in_array($relatedItemId, $violatingItemIds, true) || in_array($orderItemId, $includedItemIds, true)) {
|
|
continue;
|
|
}
|
|
$includedItemIds[] = $orderItemId;
|
|
$items[] = [
|
|
'invoice_collection_id' => (int)$collection->id,
|
|
'order_id' => (int)$row['order_id'],
|
|
'order_item_id' => $orderItemId,
|
|
'product_id' => (int)$row['product_id'],
|
|
'product_name' => (string)$row['product_name'],
|
|
'rule' => 'related_to_removed_item',
|
|
'price' => (int)$row['price'],
|
|
'quantity' => (int)$row['quantity'],
|
|
'will_soft_delete' => true,
|
|
];
|
|
}
|
|
}
|
|
|
|
return [
|
|
...$preview,
|
|
'order_items' => $items,
|
|
'summary' => [
|
|
'collections' => count($collections),
|
|
'order_items' => count($items),
|
|
'changed_count' => count($items),
|
|
],
|
|
'blockers' => $blockers,
|
|
];
|
|
}
|
|
|
|
private function previewMerge(array $preview, array $collections, array $options): array
|
|
{
|
|
$targetId = (int)($options['target_invoice_collection_id'] ?? 0);
|
|
$blockers = [];
|
|
if (count($collections) < 2) {
|
|
$blockers[] = ['code' => 'merge_requires_multiple_collections', 'message' => 'Merge requires at least two invoice collections.'];
|
|
}
|
|
if ($targetId < 1 || !in_array($targetId, array_map(static fn($collection): int => (int)$collection->id, $collections), true)) {
|
|
$blockers[] = ['code' => 'invalid_merge_target', 'message' => 'A selected invoice collection must be chosen as merge target.'];
|
|
}
|
|
$customerNumbers = array_values(array_unique(array_map(static fn($collection): int => (int)$collection->customer_number->value(), $collections)));
|
|
if (count($customerNumbers) !== 1) {
|
|
$blockers[] = ['code' => 'merge_cross_customer', 'message' => 'Only invoice collections for the same customer can be merged.'];
|
|
}
|
|
foreach ($collections as $collection) {
|
|
$blockers = [...$blockers, ...$this->contentMutationBlockers($collection)];
|
|
}
|
|
|
|
$ordersToMove = [];
|
|
foreach ($collections as $collection) {
|
|
if ((int)$collection->id === $targetId) {
|
|
continue;
|
|
}
|
|
foreach ($collection->getOrderIds() as $orderIdRow) {
|
|
$ordersToMove[] = [
|
|
'order_id' => (int)$orderIdRow['id'],
|
|
'source_invoice_collection_id' => (int)$collection->id,
|
|
'target_invoice_collection_id' => $targetId,
|
|
];
|
|
}
|
|
}
|
|
|
|
return [
|
|
...$preview,
|
|
'target_invoice_collection_id' => $targetId,
|
|
'orders' => $ordersToMove,
|
|
'summary' => [
|
|
'collections' => count($collections),
|
|
'orders_to_move' => count($ordersToMove),
|
|
'changed_count' => count($ordersToMove),
|
|
],
|
|
'blockers' => $blockers,
|
|
];
|
|
}
|
|
|
|
private function previewSplitByMonth(array $preview, array $collections): array
|
|
{
|
|
$items = [];
|
|
$changed = [];
|
|
$skipped = [];
|
|
foreach ($collections as $collection) {
|
|
try {
|
|
$item = [
|
|
'invoice_collection_id' => (int)$collection->id,
|
|
...$collection->previewSplitByOrderMonth(),
|
|
];
|
|
if (($item['status'] ?? '') === 'changed') {
|
|
$changed[] = $item;
|
|
} else {
|
|
$skipped[] = $item;
|
|
}
|
|
$items[] = $item;
|
|
} catch (\Throwable $e) {
|
|
$item = [
|
|
'status' => 'skipped',
|
|
'invoice_collection_id' => (int)$collection->id,
|
|
'reason' => 'not_splittable',
|
|
'message' => $e->getMessage(),
|
|
];
|
|
$skipped[] = $item;
|
|
$items[] = $item;
|
|
}
|
|
}
|
|
|
|
return [
|
|
...$preview,
|
|
'items' => $items,
|
|
'changed' => $changed,
|
|
'skipped' => $skipped,
|
|
'summary' => [
|
|
'collections' => count($collections),
|
|
'changed_count' => count($changed),
|
|
'skipped_count' => count($skipped),
|
|
],
|
|
];
|
|
}
|
|
|
|
private function previewResetHiddenPrices(array $preview, array $collections): array
|
|
{
|
|
$items = [];
|
|
$blockers = [];
|
|
foreach ($collections as $collection) {
|
|
$blockers = [...$blockers, ...$this->contentMutationBlockers($collection)];
|
|
foreach ($this->orderItemRows((int)$collection->id, true) as $row) {
|
|
if ((int)$row['include_in_invoice'] !== 0) {
|
|
continue;
|
|
}
|
|
$order = (new orders_o())->select((int)$row['order_id']);
|
|
$product = (new products_o())->select((int)$row['product_id']);
|
|
if (!$order->exists() || !$product->exists()) {
|
|
continue;
|
|
}
|
|
$newPrice = (int)$order->getCustomerProductPrice($product);
|
|
if ((int)$row['price'] === $newPrice) {
|
|
continue;
|
|
}
|
|
$items[] = [
|
|
'invoice_collection_id' => (int)$collection->id,
|
|
'order_id' => (int)$row['order_id'],
|
|
'order_item_id' => (int)$row['order_item_id'],
|
|
'product_id' => (int)$row['product_id'],
|
|
'product_name' => (string)$row['product_name'],
|
|
'current_price' => (int)$row['price'],
|
|
'new_price' => $newPrice,
|
|
];
|
|
}
|
|
}
|
|
|
|
return [
|
|
...$preview,
|
|
'order_items' => $items,
|
|
'summary' => [
|
|
'collections' => count($collections),
|
|
'order_items' => count($items),
|
|
'changed_count' => count($items),
|
|
],
|
|
'blockers' => $blockers,
|
|
];
|
|
}
|
|
|
|
private function previewQueueEconomic(array $preview, array $collections): array
|
|
{
|
|
$blockers = [];
|
|
foreach ($collections as $collection) {
|
|
if (!empty($collection->booked_invoice_id->value())) {
|
|
$blockers[] = [
|
|
'code' => 'collection_booked',
|
|
'invoice_collection_id' => (int)$collection->id,
|
|
'message' => 'Invoice collection is already booked.',
|
|
];
|
|
}
|
|
}
|
|
return [
|
|
...$preview,
|
|
'summary' => [
|
|
'collections' => count($collections),
|
|
'changed_count' => count($collections),
|
|
],
|
|
'blockers' => $blockers,
|
|
];
|
|
}
|
|
|
|
private function applyCleanCustomerRules(array $preview): array
|
|
{
|
|
global $db;
|
|
$itemIds = array_values(array_unique(array_map(static fn(array $item): int => (int)$item['order_item_id'], $preview['order_items'] ?? [])));
|
|
if ($itemIds === []) {
|
|
return ['changed_count' => 0, 'order_item_ids' => []];
|
|
}
|
|
$ids = implode(',', array_map('intval', $itemIds));
|
|
$now = date('Y-m-d H:i:s');
|
|
$safeNow = $db->escape_string($now);
|
|
$db->query("UPDATE order_items SET deleted_at = '$safeNow' WHERE deleted_at IS NULL AND id IN ($ids)");
|
|
$this->touchOrdersForItems($itemIds);
|
|
$this->touchCollections($preview['invoice_collection_ids'] ?? []);
|
|
return ['changed_count' => count($itemIds), 'order_item_ids' => $itemIds];
|
|
}
|
|
|
|
private function applyMerge(array $preview, array $options): array
|
|
{
|
|
$targetId = (int)($options['target_invoice_collection_id'] ?? 0);
|
|
$moved = [];
|
|
foreach ($preview['orders'] ?? [] as $row) {
|
|
$order = (new orders_o())->select((int)$row['order_id']);
|
|
if (!$order->exists()) {
|
|
continue;
|
|
}
|
|
$order->assignToInvoiceCollection($targetId);
|
|
$moved[] = (int)$order->id;
|
|
}
|
|
$this->touchCollections($preview['invoice_collection_ids'] ?? []);
|
|
return ['changed_count' => count($moved), 'moved_order_ids' => $moved, 'target_invoice_collection_id' => $targetId];
|
|
}
|
|
|
|
private function applySplitByMonth(array $preview): array
|
|
{
|
|
$changed = [];
|
|
$skipped = [];
|
|
foreach ($preview['items'] ?? [] as $item) {
|
|
$invoiceCollectionId = (int)($item['invoice_collection_id'] ?? 0);
|
|
if (($item['status'] ?? '') !== 'changed' || $invoiceCollectionId < 1) {
|
|
$skipped[] = $item;
|
|
continue;
|
|
}
|
|
try {
|
|
$changed[] = (new collected_order_invoices_o())->select($invoiceCollectionId)->splitByOrderMonth();
|
|
} catch (\Throwable $e) {
|
|
$skipped[] = [
|
|
'invoice_collection_id' => $invoiceCollectionId,
|
|
'status' => 'skipped',
|
|
'message' => $e->getMessage(),
|
|
];
|
|
}
|
|
}
|
|
return ['changed_count' => count($changed), 'skipped_count' => count($skipped), 'changed' => $changed, 'skipped' => $skipped];
|
|
}
|
|
|
|
private function applyResetHiddenPrices(array $preview): array
|
|
{
|
|
$changed = [];
|
|
foreach ($preview['order_items'] ?? [] as $item) {
|
|
$orderItem = (new order_items_o())->select((int)$item['order_item_id']);
|
|
if (!$orderItem->exists()) {
|
|
continue;
|
|
}
|
|
$orderItem->price->set((int)$item['new_price']);
|
|
$orderItem->objectChanged();
|
|
$changed[] = (int)$orderItem->id;
|
|
}
|
|
$this->touchCollections($preview['invoice_collection_ids'] ?? []);
|
|
return ['changed_count' => count($changed), 'order_item_ids' => $changed];
|
|
}
|
|
|
|
private function contentMutationBlockers(collected_order_invoices_o $collection): array
|
|
{
|
|
$blockers = [];
|
|
if (!empty($collection->booked_invoice_id->value())) {
|
|
$blockers[] = ['code' => 'collection_booked', 'invoice_collection_id' => (int)$collection->id, 'message' => 'Invoice collection is already booked.'];
|
|
}
|
|
if (!empty($collection->external_id->value())) {
|
|
$blockers[] = ['code' => 'collection_exported', 'invoice_collection_id' => (int)$collection->id, 'message' => 'Invoice collection already has an external invoice reference.'];
|
|
}
|
|
return $blockers;
|
|
}
|
|
|
|
private function orderItemRows(int $invoiceCollectionId, bool $includeHidden = false): array
|
|
{
|
|
global $db;
|
|
$hiddenCondition = $includeHidden ? '' : 'AND oi.include_in_invoice = 1';
|
|
$sql = "
|
|
SELECT
|
|
oi.id AS order_item_id,
|
|
oi.order_id,
|
|
oi.product_id,
|
|
oi.related_item_id,
|
|
oi.include_in_invoice,
|
|
oi.price,
|
|
oi.quantity,
|
|
p.name AS product_name
|
|
FROM order_items oi
|
|
JOIN orders o ON o.id = oi.order_id
|
|
JOIN products p ON p.id = oi.product_id
|
|
WHERE o.invoice_collection_id = {$invoiceCollectionId}
|
|
AND o.deleted_at IS NULL
|
|
AND oi.deleted_at IS NULL
|
|
{$hiddenCondition}
|
|
ORDER BY o.id ASC, oi.id ASC
|
|
";
|
|
$result = $db->query($sql);
|
|
return $result ? $result->fetch_all(MYSQLI_ASSOC) : [];
|
|
}
|
|
|
|
private function touchOrdersForItems(array $itemIds): void
|
|
{
|
|
global $db;
|
|
if ($itemIds === []) {
|
|
return;
|
|
}
|
|
$ids = implode(',', array_map('intval', $itemIds));
|
|
$result = $db->query("SELECT DISTINCT order_id FROM order_items WHERE id IN ($ids)");
|
|
if (!$result) {
|
|
return;
|
|
}
|
|
while ($row = $result->fetch_assoc()) {
|
|
$order = (new orders_o())->select((int)$row['order_id']);
|
|
if ($order->exists()) {
|
|
$order->objectChanged();
|
|
}
|
|
}
|
|
}
|
|
|
|
private function touchCollections(array $invoiceCollectionIds): void
|
|
{
|
|
foreach ($invoiceCollectionIds as $invoiceCollectionId) {
|
|
$collection = (new collected_order_invoices_o())->select((int)$invoiceCollectionId);
|
|
if ($collection->exists()) {
|
|
$collection->objectChanged();
|
|
}
|
|
}
|
|
}
|
|
|
|
private function loadCollections(array $invoiceCollectionIds): array
|
|
{
|
|
return array_map(static function (int $invoiceCollectionId): collected_order_invoices_o {
|
|
$collection = (new collected_order_invoices_o())->select($invoiceCollectionId);
|
|
$collection->requireSelected();
|
|
return $collection;
|
|
}, $invoiceCollectionIds);
|
|
}
|
|
|
|
private function collectionSummary(collected_order_invoices_o $collection): array
|
|
{
|
|
return [
|
|
'id' => (int)$collection->id,
|
|
'customer_number' => (int)$collection->customer_number->value(),
|
|
'name' => (string)$collection->name->value(),
|
|
'created_at' => (string)$collection->created_at->value(),
|
|
'closed_at' => $collection->closed_at->value(),
|
|
'booked_invoice_id' => $collection->booked_invoice_id->value(),
|
|
'external_id' => $collection->external_id->value(),
|
|
'order_count' => (int)$collection->getOrders(true),
|
|
];
|
|
}
|
|
|
|
private function normalizeAction(string $action): string
|
|
{
|
|
$action = trim($action);
|
|
if (!in_array($action, [
|
|
self::ACTION_CLEAN_CUSTOMER_RULES,
|
|
self::ACTION_MERGE,
|
|
self::ACTION_SPLIT_BY_MONTH,
|
|
self::ACTION_RESET_HIDDEN_PRICES,
|
|
self::ACTION_QUEUE_ECONOMIC,
|
|
], true)) {
|
|
throw new Exception('Invalid invoice collection bulk action.');
|
|
}
|
|
return $action;
|
|
}
|
|
|
|
private function normalizeInvoiceCollectionIds(array $ids): array
|
|
{
|
|
$normalized = [];
|
|
foreach ($ids as $id) {
|
|
if (is_array($id) || is_object($id) || !is_numeric($id)) {
|
|
throw new Exception('invoice_collection_ids must contain only positive integer ids.');
|
|
}
|
|
$parsed = (int)$id;
|
|
if ($parsed < 1 || $parsed > 999999999) {
|
|
throw new Exception('invoice_collection_ids must contain only positive integer ids.');
|
|
}
|
|
$normalized[$parsed] = $parsed;
|
|
}
|
|
$normalized = array_values($normalized);
|
|
sort($normalized);
|
|
if ($normalized === []) {
|
|
throw new Exception('invoice_collection_ids must contain at least one id.');
|
|
}
|
|
if (count($normalized) > self::MAX_COLLECTIONS) {
|
|
throw new Exception('Too many invoice collections selected.');
|
|
}
|
|
return $normalized;
|
|
}
|
|
|
|
private function normalizeOptions(array $options): array
|
|
{
|
|
if (isset($options['target_invoice_collection_id'])) {
|
|
$options['target_invoice_collection_id'] = (int)$options['target_invoice_collection_id'];
|
|
}
|
|
ksort($options);
|
|
return $options;
|
|
}
|
|
|
|
private function selectionHash(string $action, array $invoiceCollectionIds, array $options): string
|
|
{
|
|
return hash('sha256', json_encode([
|
|
'action' => $action,
|
|
'invoice_collection_ids' => $invoiceCollectionIds,
|
|
'options' => $options,
|
|
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
|
|
}
|
|
|
|
private function confirmationPhrase(string $locale): string
|
|
{
|
|
$language = strtolower(substr(trim($locale), 0, 2));
|
|
return self::CONFIRMATION_PHRASES[$language] ?? self::CONFIRMATION_PHRASES['en'];
|
|
}
|
|
|
|
private function previewId(): string
|
|
{
|
|
return bin2hex(random_bytes(16));
|
|
}
|
|
|
|
private function previewCacheKey(string $previewId): string
|
|
{
|
|
return 'collected_invoice_bulk_action_preview:' . preg_replace('/[^a-f0-9]/', '', strtolower($previewId));
|
|
}
|
|
|
|
private function cachePreview(string $previewId, array $payload): void
|
|
{
|
|
(new redis())->setEx($this->previewCacheKey($previewId), json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), self::PREVIEW_TTL_SECONDS);
|
|
}
|
|
|
|
private function getCachedPreview(string $previewId): ?array
|
|
{
|
|
$raw = (new redis())->get($this->previewCacheKey($previewId));
|
|
if (!$raw) {
|
|
return null;
|
|
}
|
|
$decoded = json_decode($raw, true);
|
|
return is_array($decoded) ? $decoded : null;
|
|
}
|
|
|
|
private function deleteCachedPreview(string $previewId): void
|
|
{
|
|
(new redis())->delete($this->previewCacheKey($previewId));
|
|
}
|
|
}
|