diff --git a/RELEASE.md b/RELEASE.md index d938d0d4..7b7bcf30 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,16 +1,19 @@ -> v0.4.24 ~ "Extensions can add columns, actions and buttons to storefront tables and panels" +> v0.4.25 ~ "Verified reviews, order chat with the driver, richer public promotions and faster store listings" --- ## Highlights -- **Resource view registries.** Extensions can add the following through `storefront::table:` and `storefront::details:`: - - columns, row actions, bulk actions and toolbar buttons on the orders, customers, promotions, campaigns, segments and network tables; - - buttons and menu items on the order and customer panels. -- **Orders use the standard table layout.** +- **Verified-purchase reviews.** Reviews can be tied to a delivered order and are marked verified. A new eligibility endpoint tells the app whether a customer can review a store, and customers can delete their own reviews. Reviewer email and phone are no longer exposed publicly; public reviews show a short name such as "Ada B.". +- **Chat with the driver.** Customers can chat with the driver delivering their order: get the chat, page through messages, send text and photos, and mark messages read. New messages are broadcast on the chat's `chat.{id}` channel. +- **Public promotions.** Public promotion payloads now include the owning store, the shareable code, availability and the next start time. +- **Product options.** Public product payloads now include each add-on category's `is_required` and `max_selectable`. +- **Faster store listings.** Network and Console store listings are quicker. +- **Private review photos.** Review photos upload without a public ACL, so private buckets work. --- ## Upgrading -Needs fleetbase/ember-core v0.3.25 and fleetbase/ember-ui v0.4.5. +- Run migrations. This release adds an index on `reviews.subject_uuid` and an `order_uuid` column on `reviews`. +- Order chat needs storefront socket authentication and fleetbase/core-api v1.6.69 or later. --- ## Need help? diff --git a/addon/components/modals/assign-driver.hbs b/addon/components/modals/assign-driver.hbs index 02f6de6a..88b0947d 100644 --- a/addon/components/modals/assign-driver.hbs +++ b/addon/components/modals/assign-driver.hbs @@ -16,6 +16,7 @@ - {{model.name}} + diff --git a/addon/components/modals/create-food-truck.hbs b/addon/components/modals/create-food-truck.hbs index 41b9a9e9..3332b6b6 100644 --- a/addon/components/modals/create-food-truck.hbs +++ b/addon/components/modals/create-food-truck.hbs @@ -3,6 +3,7 @@ -
-
- -
-
-
{{model.display_name}}
-
{{model.plate_number}}
-
-
+
diff --git a/addon/components/modals/create-network-category.hbs b/addon/components/modals/create-network-category.hbs index 0a489992..832f5419 100644 --- a/addon/components/modals/create-network-category.hbs +++ b/addon/components/modals/create-network-category.hbs @@ -12,7 +12,7 @@ @onChange={{@options.setParentCategory}} as |model| > - {{model.name}} + - {{model.name}} + diff --git a/addon/components/modals/incoming-order.hbs b/addon/components/modals/incoming-order.hbs index 9accc241..fba4a8f3 100644 --- a/addon/components/modals/incoming-order.hbs +++ b/addon/components/modals/incoming-order.hbs @@ -34,16 +34,7 @@
{{t "storefront.component.modals.incoming-order.assigned"}}
{{#if @options.order.driver_assigned.id}} -
- {{@options.order.driver_assigned.name}} -
-
{{n-a @options.order.driver_assigned.displayName}}
-
{{n-a - @options.order.driver_assigned.phone - (t "storefront.component.modals.incoming-order.no-phone") - }}
-
-
+ {{else}}
{{t "storefront.component.modals.incoming-order.not-assigned"}}
@@ -72,16 +63,9 @@
{{t "storefront.common.customer"}}
-
-
- {{@options.order.customer.name}} -
-
-
{{@options.order.customer.name}}
-
{{@options.order.customer.email}}
-
{{@options.order.customer.phone}}
-
-
+ +
{{n-a @options.order.customer.email}}
+
{{n-a @options.order.customer.phone}}
{{#unless @options.order.meta.is_pickup}}
{{t "storefront.component.modals.incoming-order.address"}}
diff --git a/addon/components/modals/order-ready-assign-driver.hbs b/addon/components/modals/order-ready-assign-driver.hbs index 83e23bc6..9e313645 100644 --- a/addon/components/modals/order-ready-assign-driver.hbs +++ b/addon/components/modals/order-ready-assign-driver.hbs @@ -29,6 +29,7 @@ - {{model.name}} + {{/unless}} diff --git a/addon/components/storefront/order/details/commerce-summary.hbs b/addon/components/storefront/order/details/commerce-summary.hbs index 4904e39b..b1218291 100644 --- a/addon/components/storefront/order/details/commerce-summary.hbs +++ b/addon/components/storefront/order/details/commerce-summary.hbs @@ -56,6 +56,17 @@ {{#unless @resource.meta.is_pickup}}
Delivery fee{{format-currency @resource.meta.delivery_fee @resource.meta.currency}}
{{/unless}} + {{#each this.promotions as |promotion|}} +
+ + {{promotion.name}}{{#if promotion.code}} · {{promotion.code}}{{/if}} + + -{{format-currency promotion.amount @resource.meta.currency}} +
+ {{/each}} + {{#if this.unattributedDiscount}} +
Discount-{{format-currency this.unattributedDiscount @resource.meta.currency}}
+ {{/if}} {{#if @resource.meta.tip}}
Tip{{get-tip-amount @resource.meta.tip @resource.meta.subtotal @resource.meta.currency}}
{{/if}} diff --git a/addon/components/storefront/order/details/commerce-summary.js b/addon/components/storefront/order/details/commerce-summary.js index 882ecda7..9e1b1419 100644 --- a/addon/components/storefront/order/details/commerce-summary.js +++ b/addon/components/storefront/order/details/commerce-summary.js @@ -1,3 +1,25 @@ import Component from '@glimmer/component'; -export default class StorefrontOrderDetailsCommerceSummaryComponent extends Component {} +export default class StorefrontOrderDetailsCommerceSummaryComponent extends Component { + /** Each promotion applied at checkout, with what it took off the order (items and delivery). */ + get promotions() { + const promotions = this.args.resource?.meta?.promotions; + if (!Array.isArray(promotions)) { + return []; + } + + return promotions.map((promotion) => ({ + name: promotion.name || promotion.code || 'Promotion', + code: promotion.code, + isFreeDelivery: promotion.type === 'free_delivery', + amount: (Number(promotion.amount) || 0) + (Number(promotion.delivery_amount) || 0), + })); + } + + /** The order's discount when no promotion breakdown was recorded. */ + get unattributedDiscount() { + const discount = Number(this.args.resource?.meta?.discount) || 0; + + return this.promotions.length === 0 && discount > 0 ? discount : 0; + } +} diff --git a/addon/components/storefront/order/details/customer.hbs b/addon/components/storefront/order/details/customer.hbs index d0eb04a3..59957374 100644 --- a/addon/components/storefront/order/details/customer.hbs +++ b/addon/components/storefront/order/details/customer.hbs @@ -1,17 +1,9 @@
-
- {{@resource.customer.name}} -
-
{{n-a @resource.customer.name "No customer"}}
-
{{n-a @resource.customer.email "No email"}}
-
{{n-a @resource.customer.phone "No phone"}}
-
+ +
+
Email{{n-a @resource.customer.email "No email"}}
+
Phone{{n-a @resource.customer.phone "No phone"}}
{{#unless @resource.meta.is_pickup}} diff --git a/addon/components/storefront/order/details/detail.hbs b/addon/components/storefront/order/details/detail.hbs index ca92fadc..9ffc644b 100644 --- a/addon/components/storefront/order/details/detail.hbs +++ b/addon/components/storefront/order/details/detail.hbs @@ -3,27 +3,13 @@
Customer
-
- {{@resource.customer.name}} -
-
{{n-a @resource.customer.name "No customer"}}
-
{{n-a (or @resource.customer.phone @resource.customer.email) "No contact details"}}
-
-
+
Driver
-
- {{@resource.driver_assigned.name}} -
-
- {{n-a (or @resource.driver_assigned.displayName @resource.driver_assigned.name) "No driver assigned"}} -
-
{{n-a @resource.driver_assigned.phone "No phone"}}
-
-
+
diff --git a/addon/components/storefront/order/details/fulfillment.hbs b/addon/components/storefront/order/details/fulfillment.hbs index 508ca07d..36e5b262 100644 --- a/addon/components/storefront/order/details/fulfillment.hbs +++ b/addon/components/storefront/order/details/fulfillment.hbs @@ -3,18 +3,7 @@
Driver
-
- {{@resource.driver_assigned.name}} -
-
{{n-a (or @resource.driver_assigned.displayName @resource.driver_assigned.name) "No driver assigned"}}
-
{{n-a @resource.driver_assigned.phone "No phone"}}
-
-
+
Schedule
diff --git a/addon/components/widget/customers.hbs b/addon/components/widget/customers.hbs index f9268eb5..afb75753 100644 --- a/addon/components/widget/customers.hbs +++ b/addon/components/widget/customers.hbs @@ -15,10 +15,8 @@ {{#each this.customers as |customer|}}
- {{customer.name}}
- {{n-a customer.name}} -
{{customer.public_id}}
+
{{n-a customer.orders 0}}
@@ -32,7 +30,7 @@ {{/each}}
{{else}} -
+
@@ -48,10 +46,7 @@ diff --git a/addon/components/widget/orders.hbs b/addon/components/widget/orders.hbs index 3ea48856..d256ad12 100644 --- a/addon/components/widget/orders.hbs +++ b/addon/components/widget/orders.hbs @@ -26,14 +26,12 @@
- {{n-a order.customer.name}} - - {{#if order.meta.is_pickup}} - {{t "storefront.component.widget.orders.pickup-order"}} - {{else}} - {{n-a order.driver_assigned.name}} - {{/if}} - + + {{#if order.meta.is_pickup}} + {{t "storefront.component.widget.orders.pickup-order"}} + {{else}} + + {{/if}}
@@ -46,7 +44,7 @@ {{/each}}
{{else}} -
+
{{customer.public_id}} -
- {{customer.name}} - {{n-a customer.name}} -
+
{{n-a customer.phone}} {{n-a customer.email}}
@@ -68,14 +66,14 @@ - + diff --git a/addon/controllers/customers/index.js b/addon/controllers/customers/index.js index 6242a732..72ffa14e 100644 --- a/addon/controllers/customers/index.js +++ b/addon/controllers/customers/index.js @@ -123,7 +123,8 @@ export default class CustomersIndexController extends BaseController { label: this.intl.t('storefront.common.name'), valuePath: 'name', width: '15%', - cellComponent: 'table/cell/media-name', + cellComponent: 'table/cell/identity', + resourceType: 'customer', action: this.viewCustomer, resizable: true, sortable: true, diff --git a/addon/controllers/networks/index/network/customers.js b/addon/controllers/networks/index/network/customers.js index 26776546..6d8f8dfc 100644 --- a/addon/controllers/networks/index/network/customers.js +++ b/addon/controllers/networks/index/network/customers.js @@ -15,7 +15,8 @@ export default class NetworksIndexNetworkCustomersController extends CustomersIn label: this.intl.t('storefront.common.name'), valuePath: 'name', width: '25%', - cellComponent: 'table/cell/media-name', + cellComponent: 'table/cell/identity', + resourceType: 'customer', action: this.viewCustomer, resizable: true, sortable: true, diff --git a/addon/controllers/networks/index/network/index.js b/addon/controllers/networks/index/network/index.js index 5902ddd9..e44bed45 100644 --- a/addon/controllers/networks/index/network/index.js +++ b/addon/controllers/networks/index/network/index.js @@ -152,13 +152,17 @@ export default class NetworksIndexNetworkIndexController extends Controller { this.editGateway(gateway, { title: this.intl.t('storefront.networks.index.network.index.create-new-payment-gateway'), acceptButtonText: this.intl.t('storefront.networks.index.network.index.save-gateway'), - confirm: (modal) => { + confirm: async (modal) => { modal.startLoading(); - return gateway.save().then((gateway) => { + try { + await gateway.save(); this.notifications.success(this.intl.t('storefront.networks.index.network.index.new-gateway-add-network')); this.gateways.pushObject(gateway); - }); + } catch (error) { + modal.stopLoading(); + this.notifications.serverError(error); + } }, decline: (modal) => { gateway.destroyRecord(); @@ -180,14 +184,9 @@ export default class NetworksIndexNetworkIndexController extends Controller { options = {}; } - if (!options.confirm) { - options.confirm = (modal) => { - modal.startLoading(); - - return gateway.save().then(() => { - this.notifications.success(this.intl.t('storefront.networks.index.network.index.payment-gateway-changes-success')); - }); - }; + // The settings controller's modal saves the gateway and reports errors; only the message differs. + if (!options.successNotification) { + options.successNotification = this.intl.t('storefront.networks.index.network.index.payment-gateway-changes-success'); } return this.gatewaysController.editGateway(gateway, options); diff --git a/addon/controllers/networks/index/network/orders.js b/addon/controllers/networks/index/network/orders.js index eccfe065..2411a5d0 100644 --- a/addon/controllers/networks/index/network/orders.js +++ b/addon/controllers/networks/index/network/orders.js @@ -1,4 +1,6 @@ import BaseController from '@fleetbase/storefront-engine/controllers/base-controller'; +import { relationValue } from '@fleetbase/ember-ui/utils/resource-registry'; +import { buildIdentityStub } from '@fleetbase/fleetops-data/utils/identity-stub'; import { inject as service } from '@ember/service'; import { tracked, cached } from '@glimmer/tracking'; import { isBlank } from '@ember/utils'; @@ -11,6 +13,7 @@ export default class NetworksIndexNetworkOrdersController extends BaseController * * @var {Service} */ + @service store; @service notifications; /** @@ -101,7 +104,11 @@ export default class NetworksIndexNetworkOrdersController extends BaseController id: 'customer-name', label: this.intl.t('storefront.orders.index.customer'), valuePath: 'customer.name', - cellComponent: 'table/cell/base', + cellComponent: 'table/cell/identity', + resourceType: 'customer', + resourcePath: (order) => relationValue(order, 'customer') ?? buildIdentityStub(order, { type: order.customer_type ?? 'customer', nameKey: 'customer_name' }), + action: this.viewCustomer, + emptyText: 'No customer', width: '125px', resizable: true, sortable: true, @@ -116,7 +123,9 @@ export default class NetworksIndexNetworkOrdersController extends BaseController id: 'pickup-name', label: this.intl.t('storefront.common.pickup'), valuePath: 'pickupName', - cellComponent: 'table/cell/base', + cellComponent: 'table/cell/identity', + resourceType: 'place', + resourcePath: (order) => relationValue(relationValue(order, 'payload'), 'pickup') ?? buildIdentityStub(order, { type: 'place', nameKey: 'pickupName' }), width: '160px', resizable: true, sortable: true, @@ -130,7 +139,9 @@ export default class NetworksIndexNetworkOrdersController extends BaseController id: 'dropoff-name', label: this.intl.t('storefront.common.dropoff'), valuePath: 'dropoffName', - cellComponent: 'table/cell/base', + cellComponent: 'table/cell/identity', + resourceType: 'place', + resourcePath: (order) => relationValue(relationValue(order, 'payload'), 'dropoff') ?? buildIdentityStub(order, { type: 'place', nameKey: 'dropoffName' }), width: '160px', resizable: true, sortable: true, @@ -185,9 +196,13 @@ export default class NetworksIndexNetworkOrdersController extends BaseController { id: 'driver-assigned', label: this.intl.t('storefront.orders.index.driver-assigned'), - cellComponent: 'table/cell/driver-name', + cellComponent: 'table/cell/identity', + resourceType: 'driver', valuePath: 'driver_assigned', - modelPath: 'driver_assigned', + resourcePath: (order) => + relationValue(order, 'driver_assigned') ?? + buildIdentityStub(order, { type: 'driver', load: () => (order.driver_assigned_uuid ? this.store.findRecord('driver', order.driver_assigned_uuid) : null) }), + emptyText: 'No driver assigned', width: '170px', resizable: true, sortable: true, @@ -339,6 +354,19 @@ export default class NetworksIndexNetworkOrdersController extends BaseController this.query = value; } + /** + * Opens the storefront customer behind an order. A stub that only carries + * the customer's name has no page to open. + * @param {Object} customer + */ + @action viewCustomer(customer) { + if (!customer?.public_id) { + return; + } + + return this.transitionToRoute('customers.index.view', customer.public_id); + } + @action viewOrder(order) { return this.storefrontOrderActions.viewOrder(order); } diff --git a/addon/controllers/orders/index.js b/addon/controllers/orders/index.js index e5575279..1e77eb19 100644 --- a/addon/controllers/orders/index.js +++ b/addon/controllers/orders/index.js @@ -1,4 +1,6 @@ import BaseController from '@fleetbase/storefront-engine/controllers/base-controller'; +import { relationValue } from '@fleetbase/ember-ui/utils/resource-registry'; +import { buildIdentityStub } from '@fleetbase/fleetops-data/utils/identity-stub'; import { tracked } from '@glimmer/tracking'; import { action, get } from '@ember/object'; import { inject as service } from '@ember/service'; @@ -7,6 +9,7 @@ import { isArray } from '@ember/array'; import { timeout, task } from 'ember-concurrency'; export default class OrdersIndexController extends BaseController { + @service store; @service notifications; @service intl; @service modalsManager; @@ -131,7 +134,11 @@ export default class OrdersIndexController extends BaseController { id: 'customer-name', label: this.intl.t('storefront.orders.index.customer'), valuePath: 'customer.name', - cellComponent: 'table/cell/base', + cellComponent: 'table/cell/identity', + resourceType: 'customer', + resourcePath: (order) => relationValue(order, 'customer') ?? buildIdentityStub(order, { type: order.customer_type ?? 'customer', nameKey: 'customer_name' }), + action: this.viewCustomer, + emptyText: 'No customer', width: '100px', resizable: true, sortable: true, @@ -157,7 +164,9 @@ export default class OrdersIndexController extends BaseController { id: 'pickup-name', label: this.intl.t('storefront.common.pickup'), valuePath: 'pickupName', - cellComponent: 'table/cell/base', + cellComponent: 'table/cell/identity', + resourceType: 'place', + resourcePath: (order) => relationValue(relationValue(order, 'payload'), 'pickup') ?? buildIdentityStub(order, { type: 'place', nameKey: 'pickupName' }), width: '150px', resizable: true, sortable: true, @@ -171,7 +180,9 @@ export default class OrdersIndexController extends BaseController { id: 'dropoff-name', label: this.intl.t('storefront.common.dropoff'), valuePath: 'dropoffName', - cellComponent: 'table/cell/base', + cellComponent: 'table/cell/identity', + resourceType: 'place', + resourcePath: (order) => relationValue(relationValue(order, 'payload'), 'dropoff') ?? buildIdentityStub(order, { type: 'place', nameKey: 'dropoffName' }), width: '150px', resizable: true, sortable: true, @@ -184,9 +195,13 @@ export default class OrdersIndexController extends BaseController { { id: 'driver-assigned', label: this.intl.t('storefront.orders.index.driver-assigned'), - cellComponent: 'table/cell/driver-name', + cellComponent: 'table/cell/identity', + resourceType: 'driver', valuePath: 'driver_assigned', - modelPath: 'driver_assigned', + resourcePath: (order) => + relationValue(order, 'driver_assigned') ?? + buildIdentityStub(order, { type: 'driver', load: () => (order.driver_assigned_uuid ? this.store.findRecord('driver', order.driver_assigned_uuid) : null) }), + emptyText: 'No driver assigned', width: '150px', resizable: true, sortable: true, @@ -388,6 +403,19 @@ export default class OrdersIndexController extends BaseController { this.query = value; } + /** + * Opens the storefront customer behind an order. A stub that only carries + * the customer's name has no page to open. + * @param {Object} customer + */ + @action viewCustomer(customer) { + if (!customer?.public_id) { + return; + } + + return this.transitionToRoute('customers.index.view', customer.public_id); + } + @action viewOrder(order) { return this.transitionToRoute('orders.index.view', order); } diff --git a/addon/controllers/orders/index/view.js b/addon/controllers/orders/index/view.js index 0ac7c1ce..9e8b2008 100644 --- a/addon/controllers/orders/index/view.js +++ b/addon/controllers/orders/index/view.js @@ -3,21 +3,40 @@ import BaseController from '../../base-controller'; import { inject as service } from '@ember/service'; import { isArray } from '@ember/array'; +const TAB_ICONS = { + items: 'box-open', + route: 'route', + payment: 'credit-card', + activity: 'wave-square', + customer: 'user', + data: 'database', +}; + export default class OrdersIndexViewController extends BaseController { @service storefrontOrderActions; + @service intl; @service('universe/menu-service') menuService; + /** + * Items, Route, Payment, Activity, Customer and Data; tabs registered by other + * extensions on `storefront:component:order:details` are appended. + */ get tabs() { const registeredTabs = this.menuService.getMenuItems('storefront:component:order:details'); + const ownTabs = Object.keys(TAB_ICONS).map((id) => ({ + id, + label: this.intl.t(`storefront.order.tabs.${id}`), + icon: TAB_ICONS[id], + component: `storefront/order/details/tabs/${id}`, + })); + const extraTabs = (isArray(registeredTabs) ? registeredTabs : []).map((item) => ({ + id: `registered:${item.slug ?? item.id}`, + label: item.title ?? item.label, + icon: item.icon, + registeredSlug: item.slug ?? item.id, + })); - return [ - { - route: 'orders.index.view.index', - label: 'Overview', - icon: 'folder-open', - }, - ...(isArray(registeredTabs) ? registeredTabs : []), - ]; + return [...ownTabs, ...extraTabs]; } get actionButtons() { diff --git a/addon/services/storefront-order-workflow.js b/addon/services/storefront-order-workflow.js index cae4f70b..e012ee5a 100644 --- a/addon/services/storefront-order-workflow.js +++ b/addon/services/storefront-order-workflow.js @@ -19,6 +19,11 @@ export default class StorefrontOrderWorkflowService extends Service { return toBoolean(order?.meta?.is_pickup) === true; } + /** A service booking (at the customer's address, or at the store when it is a pickup). */ + isBookingOrder(order) { + return toBoolean(order?.meta?.is_booking) === true; + } + isDefaultStorefrontConfig(order) { const orderConfig = order?.order_config; @@ -89,6 +94,12 @@ export default class StorefrontOrderWorkflowService extends Service { } if (status === 'accepted') { + // An at-store booking starts when the customer comes in; a home visit is dispatched to the provider. + if (this.isBookingOrder(order) && this.isPickupOrder(order)) { + const flow = order?.order_config?.flow ?? {}; + return [this.descriptorFor('update_activity', order, flow.in_progress ?? { code: 'in_progress', key: 'in_progress', status: 'Service in progress' })]; + } + return [this.descriptorFor('mark_ready', order)]; } @@ -101,6 +112,10 @@ export default class StorefrontOrderWorkflowService extends Service { } if (['completed', 'picked_up'].includes(code)) { + if (this.isBookingOrder(order)) { + return this.descriptorFor('mark_completed', order); + } + return this.descriptorFor(this.isPickupOrder(order) ? 'mark_picked_up' : 'mark_completed', order); } @@ -114,12 +129,16 @@ export default class StorefrontOrderWorkflowService extends Service { case 'accept': return { action, - text: 'Accept order', + text: this.isBookingOrder(order) ? 'Confirm booking' : 'Accept order', icon: 'check', type: 'success', }; case 'mark_ready': + if (this.isBookingOrder(order)) { + return { action, text: 'Dispatch provider', icon: 'paper-plane', type: 'magic' }; + } + return { action, text: 'Mark as Ready', @@ -138,7 +157,7 @@ export default class StorefrontOrderWorkflowService extends Service { case 'mark_completed': return { action, - text: 'Complete order', + text: this.isBookingOrder(order) ? 'Complete booking' : 'Complete order', icon: 'check', type: 'success', }; diff --git a/addon/styles/storefront-engine.css b/addon/styles/storefront-engine.css index 1b4350cb..e3f3fbc2 100644 --- a/addon/styles/storefront-engine.css +++ b/addon/styles/storefront-engine.css @@ -1912,22 +1912,6 @@ body[data-theme='dark'] .storefront-widget-count { background-color: #1d4ed8; } -.storefront-customer-cell { - display: flex; - min-width: 0; - align-items: center; - gap: 0.65rem; -} - -.storefront-customer-avatar { - width: 2rem; - height: 2rem; - flex: 0 0 auto; - border-radius: 0.35rem; - object-fit: cover; - box-shadow: 0 1px 2px rgb(15 23 42 / 16%); -} - .storefront-customer-name { display: block; overflow: hidden; @@ -2050,9 +2034,9 @@ body[data-theme='dark'] .storefront-order-panel-header { .storefront-order-panel-heading { display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 1rem; + flex-direction: column; + align-items: stretch; + gap: 0.625rem; padding: 0.75rem 1rem; } @@ -2244,24 +2228,12 @@ body[data-theme='dark'] .storefront-order-totals strong { gap: 0.75rem; } -.storefront-order-person--compact { - align-items: flex-start; -} - .storefront-order-store { display: flex; align-items: flex-start; gap: 0.9rem; } -.storefront-order-person__avatar { - width: 2.75rem; - height: 2.75rem; - flex: 0 0 auto; - border-radius: 0.5rem; - object-fit: cover; -} - .storefront-order-person__name { overflow: hidden; color: #111827; @@ -2275,19 +2247,6 @@ body[data-theme='dark'] .storefront-order-person__name { color: #f8fafc; } -.storefront-order-person__meta { - overflow: hidden; - color: #64748b; - font-size: 0.75rem; - font-weight: 600; - text-overflow: ellipsis; - white-space: nowrap; -} - -body[data-theme='dark'] .storefront-order-person__meta { - color: #94a3b8; -} - .storefront-order-facts, .storefront-order-totals { display: flex; diff --git a/addon/templates/food-trucks/index.hbs b/addon/templates/food-trucks/index.hbs index b707b1dd..61cf0250 100644 --- a/addon/templates/food-trucks/index.hbs +++ b/addon/templates/food-trucks/index.hbs @@ -9,11 +9,8 @@ {{#each @model as |foodTruck|}}
-
- -
-
-
{{foodTruck.vehicle.display_name}}
+
+
{{n-a foodTruck.service_area.name}}
diff --git a/addon/templates/networks/index/network/index.hbs b/addon/templates/networks/index/network/index.hbs index b5997801..e0d28ae5 100644 --- a/addon/templates/networks/index/network/index.hbs +++ b/addon/templates/networks/index/network/index.hbs @@ -139,6 +139,7 @@ - {{model.name}} +
@@ -268,6 +269,16 @@ }}
+ {{#if (and @model.options.tips_enabled @model.options.multi_cart_enabled)}} + {{! Off: a multi-store order's store tip goes to the network. On: it is split across the stores by their share of the order. }} +
+ + {{t + "storefront.networks.index.network.index.general-network-settings-form.config-switches.split-tips-across-stores" + }} + +
+ {{/if}}
{{t @@ -334,9 +345,11 @@ {{#each this.gateways as |gateway|}} + {{! Read-only summary; changes are made in the gateway modal, which saves them. }}
@@ -367,23 +382,40 @@ {{#each-in gateway.config as |key value|}} {{#if (is-bool-value value)}}
- +
{{else}} - - + + {{/if}} {{/each-in}}
-
-
{{/each}} diff --git a/addon/templates/orders/index/view.hbs b/addon/templates/orders/index/view.hbs index 5393ed48..409372f5 100644 --- a/addon/templates/orders/index/view.hbs +++ b/addon/templates/orders/index/view.hbs @@ -7,10 +7,15 @@ @onPressCancel={{this.transitionBack}} @headerClass="storefront-order-panel-header no-bottom-border" @bodyClass="no-scroll" - @width="560px" + @width="600px" @isResizable={{true}} > - - {{outlet}} + + {{#if activeTab.registeredSlug}} + + {{else if activeTab.component}} + {{component activeTab.component resource=@model order=@model refresh=this.refreshOrder}} + {{/if}} + {{outlet}} diff --git a/addon/templates/orders/index/view/index.hbs b/addon/templates/orders/index/view/index.hbs index 11419622..e69de29b 100644 --- a/addon/templates/orders/index/view/index.hbs +++ b/addon/templates/orders/index/view/index.hbs @@ -1,2 +0,0 @@ - - diff --git a/addon/templates/promotions/push-notifications.hbs b/addon/templates/promotions/push-notifications.hbs index b74e9994..35f04270 100644 --- a/addon/templates/promotions/push-notifications.hbs +++ b/addon/templates/promotions/push-notifications.hbs @@ -37,6 +37,7 @@ -
-
- -
-
-
{{model.name}}
-
{{n-a model.email}}
-
{{n-a model.phone}}
-
-
+
{{/unless}} diff --git a/addon/templates/settings/index.hbs b/addon/templates/settings/index.hbs index 581dec48..3e3ee253 100644 --- a/addon/templates/settings/index.hbs +++ b/addon/templates/settings/index.hbs @@ -217,6 +217,7 @@ - {{model.name}} +
diff --git a/composer.json b/composer.json index 267085d0..959d4a8d 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { "name": "fleetbase/storefront-api", - "version": "0.4.24", + "version": "0.4.25", "description": "Headless Commerce & Marketplace Extension for Fleetbase", "keywords": [ "fleetbase-extension", @@ -22,7 +22,7 @@ ], "require": { "php": "^8.0", - "fleetbase/core-api": "*", + "fleetbase/core-api": "^1.6.69", "fleetbase/fleetops-api": "*", "geocoder-php/google-maps-places-provider": "^1.4", "laravel-notification-channels/apn": "^5.0", diff --git a/docs/promotions.md b/docs/promotions.md index c2264460..738b8a17 100644 --- a/docs/promotions.md +++ b/docs/promotions.md @@ -16,6 +16,13 @@ specific code disables the batch count, prefix, and length fields because they d not apply to that operation. The code list shows loading and failure states, with a retry action if it cannot load. +The customer app shows the code of a public code promotion only when the promotion +has a reusable code that is active, not assigned to a customer and not expired +(for example one specific code entered with **Manage codes**). Batches of +single-use codes are never shown; send them to customers with a campaign instead. +Public promotions with weekly hours stay visible in the app outside those hours, +marked with when they next start. + ## Campaigns Enter an internal campaign name, notification title, and message. Choose an diff --git a/extension.json b/extension.json index ab0097b4..592c0c87 100644 --- a/extension.json +++ b/extension.json @@ -1,6 +1,6 @@ { "name": "Storefront", - "version": "0.4.24", + "version": "0.4.25", "description": "Headless Commerce & Marketplace Extension for Fleetbase", "repository": "https://github.com/fleetbase/storefront", "license": "AGPL-3.0-or-later", diff --git a/package.json b/package.json index 275c0ec6..344a57db 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@fleetbase/storefront-engine", - "version": "0.4.24", + "version": "0.4.25", "description": "Headless Commerce & Marketplace Extension for Fleetbase", "fleetbase": { "route": "storefront", @@ -46,7 +46,7 @@ "@babel/core": "^7.23.2", "@fleetbase/ember-core": "^0.3.25", "@fleetbase/ember-ui": "^0.4.5", - "@fleetbase/fleetops-data": "^0.1.37", + "@fleetbase/fleetops-data": "^0.2.3", "@fortawesome/ember-fontawesome": "^2.0.0", "@fortawesome/fontawesome-svg-core": "6.4.0", "@fortawesome/free-brands-svg-icons": "6.4.0", diff --git a/server/migrations/2026_10_06_000000_add_subject_uuid_index_to_reviews_table.php b/server/migrations/2026_10_06_000000_add_subject_uuid_index_to_reviews_table.php new file mode 100644 index 00000000..3be53b54 --- /dev/null +++ b/server/migrations/2026_10_06_000000_add_subject_uuid_index_to_reviews_table.php @@ -0,0 +1,47 @@ +indexExists('reviews', 'reviews_subject_uuid_index')) { + return; + } + + Schema::connection(config('storefront.connection.db'))->table('reviews', function (Blueprint $table) { + $table->index('subject_uuid'); + }); + } + + public function down(): void + { + if (!$this->indexExists('reviews', 'reviews_subject_uuid_index')) { + return; + } + + Schema::connection(config('storefront.connection.db'))->table('reviews', function (Blueprint $table) { + $table->dropIndex(['subject_uuid']); + }); + } + + protected function indexExists(string $table, string $index): bool + { + try { + $indexes = Schema::connection(config('storefront.connection.db')) + ->getConnection() + ->getDoctrineSchemaManager() + ->listTableIndexes($table); + + return isset($indexes[$index]); + } catch (Throwable $e) { + return false; + } + } +}; diff --git a/server/migrations/2026_10_07_000000_add_order_uuid_to_reviews_table.php b/server/migrations/2026_10_07_000000_add_order_uuid_to_reviews_table.php new file mode 100644 index 00000000..6306a00f --- /dev/null +++ b/server/migrations/2026_10_07_000000_add_order_uuid_to_reviews_table.php @@ -0,0 +1,41 @@ +hasColumn('reviews', 'order_uuid')) { + return; + } + + Schema::connection($connection)->table('reviews', function (Blueprint $table) { + $table->char('order_uuid', 36)->nullable()->after('customer_uuid'); + $table->index(['customer_uuid', 'subject_uuid', 'order_uuid'], 'reviews_customer_subject_order_index'); + }); + } + + public function down(): void + { + $connection = config('storefront.connection.db'); + + if (!Schema::connection($connection)->hasColumn('reviews', 'order_uuid')) { + return; + } + + Schema::connection($connection)->table('reviews', function (Blueprint $table) { + $table->dropIndex('reviews_customer_subject_order_index'); + $table->dropColumn('order_uuid'); + }); + } +}; diff --git a/server/migrations/2026_10_10_000001_add_status_to_carts_table.php b/server/migrations/2026_10_10_000001_add_status_to_carts_table.php new file mode 100644 index 00000000..0016d624 --- /dev/null +++ b/server/migrations/2026_10_10_000001_add_status_to_carts_table.php @@ -0,0 +1,41 @@ +hasColumn('carts', 'status')) { + Schema::connection($connection)->table('carts', function (Blueprint $table) { + $table->string('status', 20)->default('open')->after('checkout_uuid')->index('carts_status_index'); + }); + } + + DB::connection($connection)->table('carts')->whereNotNull('checkout_uuid')->update(['status' => 'checked_out']); + } + + public function down(): void + { + $connection = config('storefront.connection.db'); + + if (!Schema::connection($connection)->hasColumn('carts', 'status')) { + return; + } + + Schema::connection($connection)->table('carts', function (Blueprint $table) { + $table->dropIndex('carts_status_index'); + $table->dropColumn('status'); + }); + } +}; diff --git a/server/seeders/Testing/Concerns/SeedsStorefrontFixtures.php b/server/seeders/Testing/Concerns/SeedsStorefrontFixtures.php index f4b5abe1..58d48a2b 100644 --- a/server/seeders/Testing/Concerns/SeedsStorefrontFixtures.php +++ b/server/seeders/Testing/Concerns/SeedsStorefrontFixtures.php @@ -8,6 +8,7 @@ use Fleetbase\FleetOps\Models\Payload; use Fleetbase\FleetOps\Models\Place; use Fleetbase\FleetOps\Models\ServiceQuote; +use Fleetbase\FleetOps\Models\ServiceRate; use Fleetbase\FleetOps\Support\Utils as FleetOpsUtils; use Fleetbase\LaravelMysqlSpatial\Types\Point; use Fleetbase\Models\Category; @@ -15,11 +16,13 @@ use Fleetbase\Models\Transaction; use Fleetbase\Models\TransactionItem; use Fleetbase\Storefront\Models\AddonCategory; +use Fleetbase\Storefront\Models\Campaign; use Fleetbase\Storefront\Models\Cart; use Fleetbase\Storefront\Models\Catalog; use Fleetbase\Storefront\Models\CatalogCategory; use Fleetbase\Storefront\Models\CatalogProduct; use Fleetbase\Storefront\Models\Checkout; +use Fleetbase\Storefront\Models\CustomerSegment; use Fleetbase\Storefront\Models\Gateway; use Fleetbase\Storefront\Models\Network; use Fleetbase\Storefront\Models\NetworkStore; @@ -29,12 +32,15 @@ use Fleetbase\Storefront\Models\ProductStatus; use Fleetbase\Storefront\Models\ProductVariant; use Fleetbase\Storefront\Models\ProductVariantOption; +use Fleetbase\Storefront\Models\Promotion; +use Fleetbase\Storefront\Models\PromotionCode; use Fleetbase\Storefront\Models\Review; use Fleetbase\Storefront\Models\Store; use Fleetbase\Storefront\Models\StoreHour; use Fleetbase\Storefront\Models\StoreLocation; use Fleetbase\Storefront\Support\Storefront; use Fleetbase\Support\Utils; +use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Arr; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Schema; @@ -54,7 +60,7 @@ * 'description' => '...', * 'email' => '...', 'phone' => '...', 'website' => '...', * 'tags' => ['groceries'], - * 'currency' => 'USD', 'timezone' => 'Asia/Singapore', 'pod_method' => 'scan', + * 'currency' => 'SGD', 'timezone' => 'Asia/Singapore', 'pod_method' => 'scan', * 'options' => ['auto_accept_orders' => false, ...], * 'gateway' => 'stripe' | null, * 'location' => ['name' => ..., 'street1' => ..., 'city' => ..., 'country' => 'SG', 'postal_code' => ..., 'lat' => 1.28, 'lng' => 103.85], @@ -62,6 +68,7 @@ * 'categories' => ['produce' => ['name' => 'Fresh Produce', 'description' => '...']], * 'addon_categories' => ['gift' => ['name' => ..., 'description' => ..., 'max_selectable' => 2, 'is_required' => false, 'addons' => [['Gift Wrap', 'desc', 350]]]], * 'products' => ['orchard-box' => ['name' => ..., 'description' => ..., 'price' => 2850, 'category' => 'produce', 'tags' => [], 'recommended' => true, + * 'is_service' => false, 'is_bookable' => false, 'meta' => ['duration' => 60], // services: duration in minutes * 'variants' => [['name' => 'Box Size', 'required' => true, 'multiselect' => false, 'options' => [['Small', 0], ['Family', 1200]]]], * 'addon_categories' => ['gift']]], * 'catalog' => ['name' => ..., 'description' => ..., 'categories' => ['Fresh Picks' => ['orchard-box', 'market-veg']]], @@ -74,6 +81,24 @@ trait SeedsStorefrontFixtures /** @var array dropoff place per seeded customer uuid */ protected array $customerPlaces = []; + /** + * uuid, public id and key of the stores, networks, products and vehicles this seeder + * created before, by class and seed id, so re-seeding keeps them. Apps are built against + * a storefront key, so a new key would need an app rebuild, and carts left open in an + * app point at products by id. + * + * @var array> + */ + protected array $seededIdentities = []; + + /** + * Config (Stripe keys) of the gateways this seeder created before, by seed id, so real + * keys set on a seeded gateway survive re-seeding. + * + * @var array + */ + protected array $seededGatewayConfigs = []; + /* |-------------------------------------------------------------------------- | Purging @@ -86,6 +111,8 @@ trait SeedsStorefrontFixtures */ protected function purgeStorefrontFixtures(): void { + $this->rememberSeededIdentities(); + $storeUuids = $this->seededUuids(Store::class); $networkUuids = $this->seededUuids(Network::class); $productUuids = $this->seededUuids(Product::class); @@ -97,10 +124,18 @@ protected function purgeStorefrontFixtures(): void $orderUuids = $this->seededUuids(Order::class); $transactionUuids = $this->seededUuids(Transaction::class); $customerUuids = $this->seededUuids(Contact::class); + $promotionUuids = $this->seededUuids(Promotion::class); $storeLocationUuids = Schema::connection($this->storefrontConnection())->hasTable('store_locations') ? DB::connection($this->storefrontConnection())->table('store_locations')->whereIn('store_uuid', $storeUuids)->pluck('uuid')->all() : []; + // Marketing: campaigns point at segments and promotions; codes and redemptions at promotions + $this->purgeModel(Campaign::class); + $this->deleteFrom($this->storefrontConnection(), 'promotion_redemptions', fn ($query) => $query->whereIn('promotion_uuid', $promotionUuids)); + $this->deleteFrom($this->storefrontConnection(), 'promotion_codes', fn ($query) => $query->whereIn('promotion_uuid', $promotionUuids)->orWhereIn('meta->seed', $this->seedNames())); + $this->purgeModel(Promotion::class); + $this->purgeModel(CustomerSegment::class); + // Orders, payments and logistics records $this->purgeSeededLedgerJournals($orderUuids); $this->deleteFrom($this->fleetbaseConnection(), 'transaction_items', fn ($query) => $query->whereIn('transaction_uuid', $transactionUuids)->orWhereIn('meta->seed', $this->seedNames())); @@ -133,8 +168,9 @@ protected function purgeStorefrontFixtures(): void $this->purgeModel(AddonCategory::class); $this->purgeModel(Category::class); - // Payments + // Payments and delivery pricing $this->purgeModel(Gateway::class); + $this->purgeModel(ServiceRate::class); $this->deleteFrom($this->storefrontConnection(), 'payment_methods', fn ($query) => $query->whereIn('owner_uuid', $customerUuids)); // Stores, locations and networks @@ -147,6 +183,66 @@ protected function purgeStorefrontFixtures(): void $this->customerPlaces = []; } + /** + * Record the identity of every store and network this seeder created, before purging. + */ + protected function rememberSeededIdentities(): void + { + $this->seededIdentities = []; + + // Products and vehicles too: open carts point at products, and food trucks at vehicles. + // Customers too, with the app account they signed in with, so a signed-in app keeps its customer. + foreach ([Store::class => 'meta', Network::class => 'options', Product::class => 'meta', \Fleetbase\FleetOps\Models\Vehicle::class => 'meta', Contact::class => 'meta'] as $modelClass => $column) { + foreach ($this->seededQuery($modelClass)->get() as $model) { + $tag = (array) $model->{$column}; + $seedId = $tag['seed_id'] ?? null; + if (!$seedId || ($tag['seed'] ?? null) !== $this->seedName()) { + continue; + } + + $this->seededIdentities[$modelClass][$seedId] = [ + 'uuid' => $model->uuid, + 'public_id' => $model->public_id, + 'key' => $model->key, + 'user_uuid' => $modelClass === Contact::class ? $model->user_uuid : null, + ]; + } + } + + $this->seededGatewayConfigs = []; + foreach ($this->seededQuery(Gateway::class)->get() as $gateway) { + $tag = (array) $gateway->meta; + if (($tag['seed'] ?? null) === $this->seedName() && !empty($tag['seed_id'])) { + $this->seededGatewayConfigs[$tag['seed_id']] = (array) $gateway->config; + } + } + } + + /** + * Create a store or network, keeping the uuid, public id and key it had when this + * seeder last created it. The models generate a new key on every create, so the + * remembered key and public id are written back straight after the insert. + */ + protected function createWithSeededIdentity(string $modelClass, string $seedId, array $attributes): Model + { + $previous = $this->seededIdentities[$modelClass][$seedId] ?? null; + if ($previous) { + $attributes['uuid'] = $previous['uuid']; + } + + $model = $this->createRecord($modelClass, $attributes); + + if ($previous) { + $identity = array_filter(['public_id' => $previous['public_id'], 'key' => $previous['key'] ?? null, 'user_uuid' => $previous['user_uuid'] ?? null]); + if ($identity) { + $model->getConnection()->table($model->getTable())->where('uuid', $model->uuid)->update($identity); + $model->forceFill($identity)->syncOriginal(); + } + } + + return $model; + } + protected function purgeSeededLedgerJournals(array $orderUuids): void { if (!Schema::connection($this->fleetbaseConnection())->hasTable('ledger_journals')) { @@ -177,14 +273,14 @@ protected function purgeSeededLedgerJournals(array $orderUuids): void * a location with opening hours, product categories, products with variants and * addons, a published catalog and (optionally) a Stripe gateway. * - * @return array{store: Store, location: StoreLocation|null, place: Place|null, products: array, gateway: Gateway|null} + * @return array{store: Store, location: StoreLocation|null, place: Place|null, products: array, categories: array, gateway: Gateway|null} */ protected function seedStore(Company $company, array $definition): array { $storeKey = $definition['key']; $seedId = 'store:' . $storeKey; - $store = $this->createRecord(Store::class, [ + $store = $this->createWithSeededIdentity(Store::class, $seedId, [ 'company_uuid' => $company->uuid, 'created_by_uuid' => session('user'), 'order_config_uuid' => Storefront::getOrderConfig($company)->uuid, @@ -195,7 +291,7 @@ protected function seedStore(Company $company, array $definition): array 'phone' => $definition['phone'] ?? null, 'website' => $definition['website'] ?? null, 'tags' => $definition['tags'] ?? [], - 'currency' => $definition['currency'] ?? 'USD', + 'currency' => $definition['currency'] ?? $this->seedCurrency(), 'timezone' => $definition['timezone'] ?? 'Asia/Singapore', 'pod_method' => $definition['pod_method'] ?? 'scan', 'options' => array_merge([ @@ -248,11 +344,12 @@ protected function seedStore(Company $company, array $definition): array } return [ - 'store' => $store, - 'location' => $location, - 'place' => $place, - 'products' => $products, - 'gateway' => $gateway, + 'store' => $store, + 'location' => $location, + 'place' => $place, + 'products' => $products, + 'categories' => $categories, + 'gateway' => $gateway, ]; } @@ -297,7 +394,8 @@ protected function createStoreLocation(Company $company, Store $store, string $s $this->createRecord(StoreHour::class, [ 'store_location_uuid' => $storeLocation->uuid, - 'day_of_week' => $day, + // Capitalised like the console writes them ("Monday"); clients match on it. + 'day_of_week' => ucfirst($day), 'start' => $start, 'end' => $end, ]); @@ -314,6 +412,62 @@ protected function createStoreLocation(Company $company, Store $store, string $s * are stored so the gateway is selectable and the "missing secret" checkout path * can be exercised. */ + /** + * Stripe keys for a seeded gateway: SEED_STRIPE_* when set, else the real keys already on + * this gateway from an earlier run (set in the console), else placeholders. + */ + protected function stripeGatewayConfig(string $seedId): array + { + $placeholder = ['secret_key' => 'sk_test_storefront_seed_placeholder', 'publishable_key' => 'pk_test_storefront_seed_placeholder']; + if (env('SEED_STRIPE_SECRET_KEY')) { + return ['secret_key' => env('SEED_STRIPE_SECRET_KEY'), 'publishable_key' => env('SEED_STRIPE_PUBLISHABLE_KEY') ?: $placeholder['publishable_key']]; + } + + $previous = $this->seededGatewayConfigs[$seedId] ?? []; + $secret = $previous['secret_key'] ?? null; + if (is_string($secret) && $secret !== '' && $secret !== $placeholder['secret_key']) { + return $previous; + } + + return $placeholder; + } + + /** + * The test stores and network are in Singapore, so everything they sell, quote and + * charge is in Singapore dollars. Rates only quote carts in their own currency. + */ + protected function seedCurrency(): string + { + return 'SGD'; + } + + /** + * A storefront delivery rate for the company, without a service area so every address + * gets a quote: a base fee plus a fee per kilometre, in the seeded stores' currency. + */ + protected function createDeliveryServiceRate(Company $company, string $seedId, ?string $currency = null): ServiceRate + { + $currency ??= $this->seedCurrency(); + $orderConfig = Storefront::getOrderConfig($company); + + return $this->createRecord(ServiceRate::class, [ + '_key' => $this->fixtureKey($seedId), + 'company_uuid' => $company->uuid, + 'created_by_uuid' => session('user'), + 'order_config_uuid' => $orderConfig?->uuid, + 'service_name' => 'Storefront Delivery', + 'service_type' => data_get($orderConfig, 'key', 'storefront'), + 'rate_calculation_method' => 'per_meter', + 'base_fee' => 299, + 'per_meter_flat_rate_fee' => 60, + 'per_meter_unit' => 'km', + 'currency' => $currency, + 'duration_terms' => 'Delivered within the hour', + 'estimated_days' => 0, + 'has_cod_fee' => false, + ]); + } + protected function createStripeGateway(Company $company, Store|Network $owner, string $ownerType, string $seedId): Gateway { return $this->createRecord(Gateway::class, [ @@ -326,10 +480,7 @@ protected function createStripeGateway(Company $company, Store|Network $owner, s 'code' => 'stripe', 'type' => 'stripe', 'sandbox' => true, - 'config' => [ - 'secret_key' => env('SEED_STRIPE_SECRET_KEY') ?: 'sk_test_storefront_seed_placeholder', - 'publishable_key' => env('SEED_STRIPE_PUBLISHABLE_KEY') ?: 'pk_test_storefront_seed_placeholder', - ], + 'config' => $this->stripeGatewayConfig($seedId), 'return_url' => null, 'callback_url' => null, 'meta' => $this->meta($seedId), @@ -361,7 +512,8 @@ protected function createProduct(Company $company, Store $store, string $storeKe { $salePrice = (int) ($product['sale_price'] ?? 0); - return $this->createRecord(Product::class, [ + // Same id as last time, so carts left open in an app still find the product. + return $this->createWithSeededIdentity(Product::class, 'product:' . $storeKey . ':' . $productKey, [ 'company_uuid' => $company->uuid, 'created_by_uuid' => session('user'), 'store_uuid' => $store->uuid, @@ -369,7 +521,7 @@ protected function createProduct(Company $company, Store $store, string $storeKe 'name' => $product['name'], 'description' => $product['description'] ?? null, 'tags' => $product['tags'] ?? [], - 'meta' => $this->meta('product:' . $storeKey . ':' . $productKey), + 'meta' => $this->meta('product:' . $storeKey . ':' . $productKey, $product['meta'] ?? []), 'sku' => $product['sku'] ?? 'SF-' . Str::upper(Str::slug($storeKey . '-' . $productKey)), 'price' => (int) $product['price'], 'currency' => $product['currency'] ?? $store->currency, @@ -493,6 +645,178 @@ protected function createCatalog(Company $company, Store $store, string $storeKe return $model; } + /* + |-------------------------------------------------------------------------- + | Marketing + |-------------------------------------------------------------------------- + */ + + /** + * Seed promotions (with their codes), customer segments and campaigns for a store or + * network. Product and category references are ":" + * against the seeded store bundles; dates are relative to now: + * + * [ + * 'promotions' => ['welcome' => ['name' => ..., 'type' => 'percentage', 'value' => 15, 'trigger' => 'code', 'code' => 'WELCOME15', + * 'starts_in_days' => -30, 'ends_in_days' => 30, 'applies_to' => ['products' => ['store:product']], ...]], + * 'segments' => ['loyal' => ['name' => ..., 'description' => ..., 'rules' => ['min_orders' => 3]]], + * 'campaigns' => ['launch' => ['name' => ..., 'title' => ..., 'body' => ..., 'status' => 'sent', 'segment' => 'loyal', 'promotion' => 'welcome', + * 'action' => ['type' => 'promotion'], 'sent_days_ago' => 2]], + * ] + * + * @param array, categories: array}> $bundles + * + * @return array{promotions: array, segments: array, campaigns: array} + */ + protected function seedMarketing(Company $company, Store|Network $owner, string $ownerKey, array $definition, array $bundles): array + { + $ownerType = $owner instanceof Network ? 'storefront:network' : 'storefront:store'; + $refs = $this->marketingReferences($bundles); + + $promotions = []; + foreach ($definition['promotions'] ?? [] as $key => $promotion) { + $promotions[$key] = $this->createPromotion($company, $owner, $ownerType, $ownerKey, $key, $promotion, $refs); + } + + $segments = []; + foreach ($definition['segments'] ?? [] as $key => $segment) { + $segments[$key] = $this->createRecord(CustomerSegment::class, [ + 'company_uuid' => $company->uuid, + 'created_by_uuid' => session('user'), + 'owner_uuid' => $owner->uuid, + 'owner_type' => $ownerType, + 'name' => $segment['name'], + 'description' => $segment['description'] ?? null, + 'rules' => $segment['rules'] ?? [], + 'meta' => $this->meta('segment:' . $ownerKey . ':' . $key), + ]); + } + + $campaigns = []; + foreach ($definition['campaigns'] ?? [] as $key => $campaign) { + $campaigns[$key] = $this->createCampaign($company, $owner, $ownerType, $ownerKey, $key, $campaign, $promotions, $segments, $refs); + } + + return ['promotions' => $promotions, 'segments' => $segments, 'campaigns' => $campaigns]; + } + + /** + * Seeded stores, products and categories by reference ("" and ":"). + * + * @return array{stores: array, products: array, categories: array} + */ + protected function marketingReferences(array $bundles): array + { + $refs = ['stores' => [], 'products' => [], 'categories' => []]; + foreach ($bundles as $storeKey => $bundle) { + $refs['stores'][$storeKey] = $bundle['store']; + foreach ($bundle['products'] ?? [] as $key => $product) { + $refs['products'][$storeKey . ':' . $key] = $product; + } + foreach ($bundle['categories'] ?? [] as $key => $category) { + $refs['categories'][$storeKey . ':' . $key] = $category; + } + } + + return $refs; + } + + protected function createPromotion(Company $company, Store|Network $owner, string $ownerType, string $ownerKey, string $key, array $promotion, array $refs): Promotion + { + $uuids = fn (string $kind, array $keys) => array_values(array_filter(array_map(fn ($ref) => ($refs[$kind][$ref] ?? null)?->uuid, $keys))); + + $appliesTo = []; + foreach (['products' => 'products', 'categories' => 'categories', 'stores' => 'stores', 'exclude_products' => 'products', 'exclude_categories' => 'categories'] as $field => $kind) { + if (!empty($promotion['applies_to'][$field])) { + $appliesTo[$field] = $uuids($kind, (array) $promotion['applies_to'][$field]); + } + } + + $record = $this->createRecord(Promotion::class, [ + 'company_uuid' => $company->uuid, + 'created_by_uuid' => session('user'), + 'owner_uuid' => $owner->uuid, + 'owner_type' => $ownerType, + 'name' => $promotion['name'], + 'description' => $promotion['description'] ?? null, + 'status' => $promotion['status'] ?? Promotion::STATUS_ACTIVE, + 'trigger' => isset($promotion['code']) ? Promotion::TRIGGER_CODE : Promotion::TRIGGER_AUTOMATIC, + 'type' => $promotion['type'], + 'value' => $promotion['value'] ?? null, + 'max_discount_amount' => $promotion['max_discount_amount'] ?? null, + 'currency' => $promotion['currency'] ?? $owner->currency ?? $this->seedCurrency(), + 'min_subtotal' => $promotion['min_subtotal'] ?? null, + 'min_items' => $promotion['min_items'] ?? null, + 'applies_to' => $appliesTo ?: null, + 'bogo_config' => $promotion['bogo_config'] ?? null, + 'first_order_only' => $promotion['first_order_only'] ?? false, + 'usage_limit' => $promotion['usage_limit'] ?? null, + 'usage_limit_per_customer' => $promotion['usage_limit_per_customer'] ?? null, + 'budget_amount' => $promotion['budget_amount'] ?? null, + 'stackable' => $promotion['stackable'] ?? false, + 'priority' => $promotion['priority'] ?? 0, + 'is_public' => $promotion['is_public'] ?? true, + 'starts_at' => isset($promotion['starts_in_days']) ? now()->addDays($promotion['starts_in_days'])->startOfDay() : null, + 'ends_at' => isset($promotion['ends_in_days']) ? now()->addDays($promotion['ends_in_days'])->endOfDay() : null, + 'schedule' => $promotion['schedule'] ?? null, + 'timezone' => $promotion['timezone'] ?? $owner->timezone ?? null, + 'meta' => $this->meta('promotion:' . $ownerKey . ':' . $key), + ]); + + if (isset($promotion['code'])) { + $this->createRecord(PromotionCode::class, [ + 'company_uuid' => $company->uuid, + 'promotion_uuid' => $record->uuid, + 'code' => $promotion['code'], + 'status' => 'active', + 'meta' => $this->meta('promotion-code:' . $ownerKey . ':' . $key), + ]); + } + + return $record; + } + + /** + * A campaign in any state. Sent campaigns get `sent_at` and stats; scheduled ones are + * due `send_in_days` from now, far enough out that the scheduler leaves them alone. + */ + protected function createCampaign(Company $company, Store|Network $owner, string $ownerType, string $ownerKey, string $key, array $campaign, array $promotions, array $segments, array $refs): Campaign + { + $status = $campaign['status'] ?? Campaign::STATUS_DRAFT; + $promotion = isset($campaign['promotion']) ? ($promotions[$campaign['promotion']] ?? null) : null; + $action = $campaign['action'] ?? null; + if ($action) { + $target = match ($action['type'] ?? null) { + 'promotion' => $promotion, + 'store' => $refs['stores'][$action['ref'] ?? ''] ?? null, + 'product' => $refs['products'][$action['ref'] ?? ''] ?? null, + default => null, + }; + $action = array_filter(['type' => $action['type'], 'id' => $target?->public_id, 'url' => $action['url'] ?? null]); + } + $sentAt = $status === Campaign::STATUS_SENT ? now()->subDays($campaign['sent_days_ago'] ?? 1)->setTime(10, 0) : null; + + return $this->createRecord(Campaign::class, [ + 'company_uuid' => $company->uuid, + 'created_by_uuid' => session('user'), + 'owner_uuid' => $owner->uuid, + 'owner_type' => $ownerType, + 'segment_uuid' => isset($campaign['segment']) ? ($segments[$campaign['segment']] ?? null)?->uuid : null, + 'promotion_uuid' => $promotion?->uuid, + 'name' => $campaign['name'], + 'status' => $status, + 'channels' => $campaign['channels'] ?? [Campaign::CHANNEL_PUSH, Campaign::CHANNEL_INBOX], + 'title' => $campaign['title'], + 'body' => $campaign['body'], + 'action' => $action ?: null, + 'send_at' => $status === Campaign::STATUS_SCHEDULED ? now()->addDays($campaign['send_in_days'] ?? 7)->setTime(10, 0) : $sentAt, + 'started_at' => $sentAt, + 'sent_at' => $sentAt, + 'stats' => $status === Campaign::STATUS_SENT ? ($campaign['stats'] ?? ['targeted' => 0, 'batches' => 0]) : null, + 'meta' => $this->meta('campaign:' . $ownerKey . ':' . $key), + ]); + } + /* |-------------------------------------------------------------------------- | Networks @@ -501,7 +825,7 @@ protected function createCatalog(Company $company, Store $store, string $storeKe protected function createNetwork(Company $company, array $definition): Network { - $network = $this->createRecord(Network::class, [ + $network = $this->createWithSeededIdentity(Network::class, 'network:' . $definition['key'], [ 'company_uuid' => $company->uuid, 'created_by_uuid' => session('user'), 'order_config_uuid' => Storefront::getOrderConfig($company)->uuid, @@ -512,7 +836,7 @@ protected function createNetwork(Company $company, array $definition): Network 'phone' => $definition['phone'] ?? null, 'website' => $definition['website'] ?? null, 'tags' => $definition['tags'] ?? [], - 'currency' => $definition['currency'] ?? 'USD', + 'currency' => $definition['currency'] ?? $this->seedCurrency(), 'timezone' => $definition['timezone'] ?? 'Asia/Singapore', 'pod_method' => $definition['pod_method'] ?? 'scan', 'options' => array_merge([ @@ -561,14 +885,27 @@ protected function addStoreToNetwork(Network $network, Store $store, ?Category $ */ protected function seedCustomers(Company $company, array $fixtures): array { - return array_values(array_map(fn (array $fixture) => $this->createCustomer($company, ...$fixture), $fixtures)); + return array_values(array_map(fn (array $fixture) => $this->createCustomer($company, ...$this->customerIdentity($fixture)), $fixtures)); + } + + /** + * The [name, email, phone] a seeder gives one of the shared customer fixtures. + * + * Fleet-Ops allows one customer profile per account (matched by email or phone) in a + * company, and both seeders run in the same company, so a seeder sharing the fixtures + * must give its customers identities of their own. + */ + protected function customerIdentity(array $fixture): array + { + return $fixture; } protected function createCustomer(Company $company, string $name, string $email, string $phone): Contact { $seedId = 'customer:' . Str::slug($name); - return $this->createRecord(Contact::class, [ + // Same id (and app account) as last time, so a customer signed in to an app stays signed in. + return $this->createWithSeededIdentity(Contact::class, $seedId, [ '_key' => $this->fixtureKey($seedId), 'company_uuid' => $company->uuid, 'name' => $name, diff --git a/server/seeders/Testing/MongoliaSeeder.php b/server/seeders/Testing/MongoliaSeeder.php new file mode 100644 index 00000000..2729fc0e --- /dev/null +++ b/server/seeders/Testing/MongoliaSeeder.php @@ -0,0 +1,643 @@ +seedName()]; + } + + protected function seedCurrency(): string + { + return 'MNT'; + } + + protected function seedTimezone(): string + { + return 'Asia/Ulaanbaatar'; + } + + public function run(): void + { + $company = $this->prepareCompany(); + if (!$company) { + return; + } + + $this->withoutForeignKeyConstraints(fn () => $this->purgeSeedData()); + + $network = $this->createNetwork($company, $this->networkDefinition()); + $this->createMongoliaDeliveryRate($company); + $gateway = $this->createQPayGateway($company, $network, 'storefront:network', 'gateway:' . static::NETWORK_KEY . ':qpay'); + + $categories = []; + foreach ($this->networkCategories() as $categoryKey => $category) { + $categories[$categoryKey] = $this->createNetworkCategory($company, $network, static::NETWORK_KEY, $categoryKey, $category); + } + + $customers = $this->seedCustomers($company, $this->customerFixtures()); + $orderCount = 0; + $bundles = []; + + foreach ($this->storeDefinitions() as $definition) { + $bundle = $this->seedStore($company, $definition); + if (($definition['gateway'] ?? null) === 'qpay') { + $bundle['gateway'] = $this->createQPayGateway($company, $bundle['store'], 'storefront:store', 'gateway:' . $definition['key'] . ':qpay'); + } + + $this->addStoreToNetwork($network, $bundle['store'], $categories[$definition['network_category']] ?? null); + + $orders = $this->seedStoreActivity($company, $bundle, $customers, $network, $definition['order_count'] ?? 6); + $orderCount += count($orders); + $bundles[$definition['key']] = $bundle; + } + + $marketing = $this->seedMarketing($company, $network, static::NETWORK_KEY, $this->marketingDefinition(), $bundles); + foreach ($this->storeMarketingDefinitions() as $storeKey => $definition) { + if (isset($bundles[$storeKey])) { + foreach ($this->seedMarketing($company, $bundles[$storeKey]['store'], $storeKey, $definition, $bundles) as $kind => $records) { + $marketing[$kind] = array_merge($marketing[$kind], array_values($records)); + } + } + } + + [$serviceArea, $zones] = $this->seedServiceArea($company); + $trucks = $this->seedFoodTrucks($company, $bundles[static::TRUCK_STORE], $serviceArea, $zones); + + $this->command?->info(sprintf('Seeded the Mongolian testing network for company %s with %d stores, %d food trucks and %d orders.', $company->public_id, count($bundles), count($trucks), $orderCount)); + $this->command?->info(sprintf(' Marketing: %d promotions, %d segments, %d campaigns.', count($marketing['promotions']), count($marketing['segments']), count($marketing['campaigns']))); + $this->reportStorefront($network, $gateway, ' '); + foreach ($bundles as $bundle) { + $this->reportStorefront($bundle['store'], $bundle['gateway'], ' '); + } + foreach ($trucks as $truck) { + $this->command?->line(sprintf(' Food truck %s "%s" zone=%s %s', $truck->public_id, $truck->vehicle?->name, $truck->zone?->name, $truck->vehicle?->online ? 'online' : 'offline')); + } + $this->command?->line(sprintf(' Customers sign in with %s (and the others in customerFixtures()).', $customers[0]->phone)); + } + + public function purgeSeedData(): void + { + $storeUuids = $this->seededUuids(Store::class); + $truckUuids = DB::connection($this->storefrontConnection())->table('food_trucks')->whereIn('store_uuid', $storeUuids)->pluck('uuid')->all(); + + // QPay settings entered in the Console (e.g. a live merchant for payment tests) are + // kept: a re-seed must never swap them back to the sandbox test merchant. + $this->qpayGateways = $this->seededQuery(Gateway::class)->get(['meta', 'config', 'sandbox']) + ->filter(fn ($gateway) => data_get($gateway, 'meta.seed') === $this->seedName() && data_get($gateway, 'meta.seed_id')) + ->mapWithKeys(fn ($gateway) => [data_get($gateway, 'meta.seed_id') => ['config' => (array) $gateway->config, 'sandbox' => (bool) $gateway->sandbox]]) + ->all(); + + // Food trucks have no tag of their own: remember each by its vehicle's seed id, so the + // truck keeps its id and carts holding it still find their delivery origin. + $truckIdentities = []; + $vehicleSeeds = $this->seededQuery(Vehicle::class)->get(['uuid', 'meta'])->mapWithKeys(fn ($vehicle) => [$vehicle->uuid => data_get($vehicle, 'meta.seed_id')]); + foreach (DB::connection($this->storefrontConnection())->table('food_trucks')->whereIn('uuid', $truckUuids)->get(['uuid', 'public_id', 'vehicle_uuid']) as $truck) { + $vehicleSeed = $vehicleSeeds[$truck->vehicle_uuid] ?? null; + if ($vehicleSeed) { + $truckIdentities['food-truck:' . Str::after($vehicleSeed, 'vehicle:')] = ['uuid' => $truck->uuid, 'public_id' => $truck->public_id, 'key' => null]; + } + } + + $this->deleteFrom($this->storefrontConnection(), 'catalog_subjects', fn ($query) => $query->whereIn('subject_uuid', $truckUuids)); + $this->deleteFrom($this->storefrontConnection(), 'food_trucks', fn ($query) => $query->whereIn('uuid', $truckUuids)); + + // Vehicles are purged here, before purgeStorefrontFixtures() remembers identities + // again, so remember theirs first and put them back afterwards. + $this->rememberSeededIdentities(); + $vehicleIdentities = $this->seededIdentities[Vehicle::class] ?? []; + $this->purgeModel(Vehicle::class); + $this->purgeModel(Zone::class); + $this->purgeModel(ServiceArea::class); + + $this->purgeStorefrontFixtures(); + $this->seededIdentities[Vehicle::class] = $vehicleIdentities; + $this->seededIdentities[FoodTruck::class] = $truckIdentities; + } + + /* + |-------------------------------------------------------------------------- + | Payments and delivery + |-------------------------------------------------------------------------- + */ + + /** + * A sandbox QPay gateway. Sandbox checkouts use QPay's TEST_INVOICE, so the invoice + * ids only matter for live gateways. + */ + protected function createQPayGateway(Company $company, Store|Network $owner, string $ownerType, string $seedId): Gateway + { + return $this->createRecord(Gateway::class, [ + 'company_uuid' => $company->uuid, + 'created_by_uuid' => session('user'), + 'owner_uuid' => $owner->uuid, + 'owner_type' => $ownerType, + 'name' => 'QPay', + 'description' => 'Sandbox QPay gateway seeded for Storefront testing.', + 'code' => 'qpay', + 'type' => 'qpay', + 'sandbox' => $this->qpayGateways[$seedId]['sandbox'] ?? true, + 'config' => $this->qpayGateways[$seedId]['config'] ?? [ + 'username' => env('SEED_QPAY_USERNAME', 'TEST_MERCHANT'), + 'password' => env('SEED_QPAY_PASSWORD', '123456'), + 'invoice_id' => env('SEED_QPAY_INVOICE_ID', 'TEST_INVOICE'), + 'ebarimt_invoice_id' => env('SEED_QPAY_EBARIMT_INVOICE_ID', 'TEST_EB_INVOICE'), + ], + 'return_url' => null, + 'callback_url' => null, + 'meta' => $this->meta($seedId), + ]); + } + + /** + * Delivery in tögrög: 3,000₮ plus 500₮ per kilometre. MNT has no minor unit: every amount in this seeder is whole tögrög, as the app and QPay use it. + */ + protected function createMongoliaDeliveryRate(Company $company): ServiceRate + { + $orderConfig = Storefront::getOrderConfig($company); + + return $this->createRecord(ServiceRate::class, [ + '_key' => $this->fixtureKey('service-rate:' . static::NETWORK_KEY), + 'company_uuid' => $company->uuid, + 'created_by_uuid' => session('user'), + 'order_config_uuid' => $orderConfig?->uuid, + 'service_name' => 'Улаанбаатар хүргэлт', + 'service_type' => data_get($orderConfig, 'key', 'storefront'), + 'rate_calculation_method' => 'per_meter', + 'base_fee' => 3000, + 'per_meter_flat_rate_fee' => 500, + 'per_meter_unit' => 'km', + 'currency' => 'MNT', + 'duration_terms' => 'Нэг цагийн дотор хүргэнэ', + 'estimated_days' => 0, + 'has_cod_fee' => false, + ]); + } + + /* + |-------------------------------------------------------------------------- + | Service area, zones and food trucks + |-------------------------------------------------------------------------- + */ + + /** + * A closed ring of points from [south, west, north, east] bounds. + */ + protected function ring(array $bounds): LineString + { + [$south, $west, $north, $east] = $bounds; + + return new LineString([ + new Point($south, $west), + new Point($north, $west), + new Point($north, $east), + new Point($south, $east), + new Point($south, $west), + ]); + } + + /** + * @return array{0: ServiceArea, 1: array} + */ + protected function seedServiceArea(Company $company): array + { + $serviceArea = $this->createRecord(ServiceArea::class, [ + '_key' => $this->fixtureKey('service-area:ulaanbaatar'), + 'company_uuid' => $company->uuid, + 'name' => 'Улаанбаатар', + 'type' => 'city', + 'country' => 'MN', + 'border' => new MultiPolygon([new Polygon([$this->ring([47.80, 106.70, 48.00, 107.15])])]), + 'color' => '#2563eb33', + 'stroke_color' => '#2563eb', + 'status' => 'active', + ]); + + $zones = []; + foreach ($this->zoneDefinitions() as $zoneKey => $zone) { + $zones[$zoneKey] = $this->createRecord(Zone::class, [ + '_key' => $this->fixtureKey('zone:' . $zoneKey), + 'company_uuid' => $company->uuid, + 'service_area_uuid' => $serviceArea->uuid, + 'name' => $zone['name'], + 'description' => $zone['description'], + // Passed as a spatial expression: the zone's cast keeps a Polygon object + // out of the spatial insert, which then sends it as plain text. + 'border' => new SpatialExpression(new Polygon([$this->ring($zone['bounds'])])), + 'color' => $zone['color'] . '33', + 'stroke_color' => $zone['color'], + 'status' => 'active', + ]); + } + + return [$serviceArea, $zones]; + } + + protected function zoneDefinitions(): array + { + return [ + 'central' => ['name' => 'Төв', 'description' => 'Сүхбаатарын талбай, Энхтайвны өргөн чөлөө.', 'bounds' => [47.905, 106.880, 47.930, 106.940], 'color' => '#16a34a'], + 'zaisan' => ['name' => 'Зайсан', 'description' => 'Хан-Уул, Зайсангийн орчим.', 'bounds' => [47.865, 106.880, 47.903, 106.950], 'color' => '#ea580c'], + 'bayanzurkh' => ['name' => 'Баянзүрх', 'description' => 'Баянзүрх дүүрэг, зүүн хэсэг.', 'bounds' => [47.905, 106.942, 47.940, 107.010], 'color' => '#7c3aed'], + ]; + } + + /** + * @param array{store: Store, products: array} $bundle + * @param array $zones + * + * @return FoodTruck[] + */ + protected function seedFoodTrucks(Company $company, array $bundle, ServiceArea $serviceArea, array $zones): array + { + $store = $bundle['store']; + $trucks = []; + + foreach ($this->truckDefinitions() as $truckKey => $definition) { + $seedId = 'vehicle:' . $truckKey; + $vehicle = $this->createWithSeededIdentity(Vehicle::class, $seedId, [ + 'company_uuid' => $company->uuid, + 'name' => $definition['name'], + 'make' => 'Hyundai', + 'model' => 'Porter II', + 'year' => '2021', + 'plate_number' => $definition['plate'], + 'type' => 'food_truck', + 'status' => 'active', + 'online' => $definition['online'], + 'location' => new Point($definition['position'][0], $definition['position'][1]), + 'meta' => $this->meta($seedId), + ]); + + $truck = $this->createWithSeededIdentity(FoodTruck::class, 'food-truck:' . $truckKey, [ + 'company_uuid' => $company->uuid, + 'created_by_uuid' => session('user'), + 'store_uuid' => $store->uuid, + 'vehicle_uuid' => $vehicle->uuid, + 'service_area_uuid' => $serviceArea->uuid, + 'zone_uuid' => $zones[$definition['zone']]->uuid, + 'status' => 'active', + ]); + + $catalog = $this->createCatalog($company, $store, static::TRUCK_STORE . '-' . $truckKey, $definition['catalog'], $bundle['products']); + $this->createRecord(CatalogSubject::class, [ + 'catalog_uuid' => $catalog->uuid, + 'subject_type' => FoodTruck::class, + 'subject_uuid' => $truck->uuid, + 'company_uuid' => $company->uuid, + 'created_by_uuid' => session('user'), + ]); + + $trucks[] = $truck->load(['vehicle', 'zone']); + } + + return $trucks; + } + + protected function truckDefinitions(): array + { + return [ + 'central' => [ + 'name' => 'Хаан Бууз — Төв', + 'plate' => '1234 УБА', + 'zone' => 'central', + 'online' => true, + 'position' => [47.9178, 106.9122], + 'catalog' => ['name' => 'Төв машины цэс', 'categories' => ['Бууз' => ['buuz', 'buuz-mix'], 'Хуушуур' => ['khuushuur'], 'Ундаа' => ['suutei-tsai', 'aaruul-shake', 'test-item']]], + ], + 'zaisan' => [ + 'name' => 'Хаан Бууз — Зайсан', + 'plate' => '5678 УБВ', + 'zone' => 'zaisan', + 'online' => true, + 'position' => [47.8860, 106.9170], + 'catalog' => ['name' => 'Зайсан машины цэс', 'categories' => ['Хуушуур' => ['khuushuur'], 'Шөл' => ['guriltai-shul'], 'Ундаа' => ['suutei-tsai']]], + ], + 'bayanzurkh' => [ + 'name' => 'Хаан Бууз — Баянзүрх', + 'plate' => '9012 УБГ', + 'zone' => 'bayanzurkh', + 'online' => false, + 'position' => [47.9215, 106.9710], + 'catalog' => ['name' => 'Баянзүрх машины цэс', 'categories' => ['Бууз' => ['buuz'], 'Ундаа' => ['suutei-tsai']]], + ], + ]; + } + + /* + |-------------------------------------------------------------------------- + | Customers + |-------------------------------------------------------------------------- + */ + + /** + * Emails on a real-looking domain: QPay rejects the whole invoice for addresses it + * doesn't accept, such as the reserved `.test` domain. + */ + protected function customerFixtures(): array + { + return [ + ['Болд Батбаяр', 'bold.batbayar@example.mn', '+976 9911 0001'], + ['Сарнай Ганбаатар', 'sarnai.ganbaatar@example.mn', '+976 9911 0002'], + ['Тэмүүлэн Дорж', 'temuulen.dorj@example.mn', '+976 9911 0003'], + ['Номин Энхбаяр', 'nomin.enkhbayar@example.mn', '+976 9911 0004'], + ['Ариунаа Мөнх', 'ariunaa.munkh@example.mn', '+976 9911 0005'], + ['Ганзориг Сүх', 'ganzorig.sukh@example.mn', '+976 9911 0006'], + ]; + } + + /** + * Customer addresses are in the Central zone, so the Central truck delivers to them + * and the Zaisan truck doesn't. + */ + protected function customerPlace(Company $company, Contact $customer, int $index): Place + { + if (isset($this->customerPlaces[$customer->uuid])) { + return $this->customerPlaces[$customer->uuid]; + } + + $addresses = [ + ['Энхтайвны өргөн чөлөө 44', '14200', 47.9160, 106.9105], + ['Сөүлийн гудамж 21', '14251', 47.9132, 106.9150], + ['Бага тойруу 15', '14201', 47.9205, 106.9120], + ['Чингисийн өргөн чөлөө 9', '14240', 47.9110, 106.9205], + ['Жуулчны гудамж 5', '14192', 47.9150, 106.9260], + ]; + [$street, $postal, $lat, $lng] = $addresses[$index % count($addresses)]; + $seedId = 'customer-address:' . Str::slug($customer->name); + + return $this->customerPlaces[$customer->uuid] = $this->createRecord(Place::class, [ + '_key' => $this->fixtureKey($seedId), + 'company_uuid' => $company->uuid, + 'owner_uuid' => $customer->uuid, + 'owner_type' => FleetOpsUtils::getMutationType('fleet-ops:contact'), + 'name' => 'Гэр', + 'street1' => $street, + 'city' => 'Улаанбаатар', + 'postal_code' => $postal, + 'country' => 'MN', + 'location' => new Point($lat, $lng), + 'meta' => $this->meta($seedId), + ]); + } + + /* + |-------------------------------------------------------------------------- + | Marketing (amounts in minor units) + |-------------------------------------------------------------------------- + */ + + protected function marketingDefinition(): array + { + return [ + 'promotions' => [ + 'welcome' => ['name' => 'Тавтай морил', 'description' => 'Анхны захиалгадаа 15% хөнгөлөлт, дээд тал нь 10,000₮.', 'type' => 'percentage', 'value' => 15, 'max_discount_amount' => 10000, 'min_subtotal' => 20000, 'first_order_only' => true, 'code' => 'SAIN15', 'usage_limit_per_customer' => 1, 'starts_in_days' => -30, 'ends_in_days' => 60], + 'free-delivery' => ['name' => '50,000₮-өөс дээш үнэгүй хүргэлт', 'description' => '50,000₮ ба түүнээс дээш захиалгад хүргэлт үнэгүй.', 'type' => 'free_delivery', 'min_subtotal' => 50000, 'starts_in_days' => -14, 'ends_in_days' => 30, 'priority' => 5], + 'weekend' => ['name' => 'Амралтын өдрийн 5,000₮', 'description' => 'Бямба, ням гарагт 30,000₮-өөс дээш захиалгад 5,000₮ хасна.', 'type' => 'fixed_amount', 'value' => 5000, 'min_subtotal' => 30000, 'schedule' => [['days' => [6, 7], 'start' => '00:00', 'end' => '23:59']], 'starts_in_days' => -7, 'ends_in_days' => 45], + 'upcoming' => ['name' => 'Цагаан сарын урамшуулал', 'description' => 'Удахгүй: бүх бүтээгдэхүүнд 10%.', 'type' => 'percentage', 'value' => 10, 'starts_in_days' => 5, 'ends_in_days' => 20], + ], + 'segments' => [ + 'loyal' => ['name' => 'Байнгын үйлчлүүлэгч', 'description' => 'Гурав ба түүнээс дээш захиалга.', 'rules' => ['min_orders' => 3]], + 'never' => ['name' => 'Захиалга хийгээгүй', 'description' => 'Бүртгүүлсэн ч захиалаагүй.', 'rules' => ['max_orders' => 0]], + ], + 'campaigns' => [ + 'welcome-sent' => ['name' => 'Тавтай морил', 'title' => 'Анхны захиалгадаа 15%', 'body' => 'SAIN15 кодыг ашиглаарай.', 'status' => 'sent', 'segment' => 'never', 'promotion' => 'welcome', 'action' => ['type' => 'promotion'], 'sent_days_ago' => 2, 'stats' => ['targeted' => 0, 'batches' => 0]], + ], + ]; + } + + protected function storeMarketingDefinitions(): array + { + return [ + static::TRUCK_STORE => [ + 'promotions' => [ + 'buuz-bogo' => ['name' => '2 бууз авбал 1 үнэгүй', 'description' => 'Ямар ч буузны багцад.', 'type' => 'bogo', 'bogo_config' => ['buy_quantity' => 2, 'get_quantity' => 1, 'discount_percent' => 100], 'applies_to' => ['products' => [static::TRUCK_STORE . ':buuz', static::TRUCK_STORE . ':buuz-mix']], 'starts_in_days' => -5, 'ends_in_days' => 25], + 'lunch' => ['name' => 'Өдрийн хоолны цаг', 'description' => 'Ажлын өдөр 12-14 цагт 20% хөнгөлөлт.', 'type' => 'percentage', 'value' => 20, 'schedule' => [['days' => [1, 2, 3, 4, 5], 'start' => '12:00', 'end' => '14:00']], 'starts_in_days' => -10, 'ends_in_days' => 50], + ], + ], + 'nomin-supermarket' => [ + 'promotions' => [ + 'dairy' => ['name' => 'Сүүн бүтээгдэхүүн 10%', 'description' => 'Сүү, тараг, ааруулд 10% хөнгөлөлт.', 'type' => 'percentage', 'value' => 10, 'applies_to' => ['categories' => ['nomin-supermarket:dairy']], 'starts_in_days' => -3, 'ends_in_days' => 27], + ], + ], + 'modern-salon' => [ + 'promotions' => [ + 'first-visit' => ['name' => 'Анхны үйлчилгээнд 10,000₮', 'description' => 'Шинэ үйлчлүүлэгчдэд, 30,000₮-өөс дээш.', 'type' => 'fixed_amount', 'value' => 10000, 'min_subtotal' => 30000, 'first_order_only' => true, 'code' => 'SALON10', 'starts_in_days' => -20, 'ends_in_days' => 70], + ], + ], + ]; + } + + /* + |-------------------------------------------------------------------------- + | Network and stores + |-------------------------------------------------------------------------- + */ + + protected function networkDefinition(): array + { + return [ + 'key' => static::NETWORK_KEY, + 'name' => 'Улаанбаатар Маркет', + 'description' => 'Улаанбаатарын дэлгүүр, ресторан, үйлчилгээ нэг дор. Монгол хэл, төгрөг, QPay-г туршихад зориулсан.', + 'email' => 'market@example.mn', + 'phone' => '+976 7700 0100', + 'website' => 'https://example.mn/ulaanbaatar-market', + 'tags' => ['marketplace', 'testing', 'mongolia'], + 'currency' => 'MNT', + 'timezone' => 'Asia/Ulaanbaatar', + 'pod_method' => 'scan', + 'options' => [ + 'auto_accept_orders' => false, + 'auto_dispatch' => false, + 'require_pod' => false, + 'multi_cart_enabled' => true, + 'tips_enabled' => true, + 'reviews_enabled' => true, + 'pickup_enabled' => true, + ], + ]; + } + + protected function networkCategories(): array + { + return [ + 'groceries' => ['name' => 'Хүнсний дэлгүүр', 'description' => 'Супермаркет, шинэ хүнс.', 'icon' => 'basket-shopping', 'icon_color' => '#16a34a', 'tags' => ['food']], + 'restaurants' => ['name' => 'Ресторан', 'description' => 'Хүргэлт болон авч явах хоол.', 'icon' => 'utensils', 'icon_color' => '#ea580c', 'tags' => ['food', 'meals']], + 'services' => ['name' => 'Гоо сайхан', 'description' => 'Үсчин, гоо сайхны үйлчилгээ захиалах.', 'icon' => 'calendar-check', 'icon_color' => '#7c3aed', 'tags' => ['services']], + ]; + } + + protected function storeDefinitions(): array + { + $common = ['currency' => 'MNT', 'timezone' => 'Asia/Ulaanbaatar', 'pod_method' => 'scan']; + + return [ + $common + [ + 'key' => 'nomin-supermarket', + 'network_category' => 'groceries', + 'order_count' => 6, + 'name' => 'Номин Супермаркет', + 'description' => 'Энхтайвны өргөн чөлөөн дээрх супермаркет. Өдөр бүр хүргэнэ.', + 'email' => 'nomin@example.mn', + 'phone' => '+976 7700 0201', + 'tags' => ['groceries', 'supermarket'], + 'gateway' => 'qpay', + 'options' => ['pickup_enabled' => true], + 'location' => ['name' => 'Номин Супермаркет', 'street1' => 'Энхтайвны өргөн чөлөө 34', 'city' => 'Улаанбаатар', 'postal_code' => '14200', 'country' => 'MN', 'lat' => 47.9166, 'lng' => 106.9050], + 'hours' => ['start' => '08:00', 'end' => '23:00'], + 'categories' => [ + 'dairy' => ['name' => 'Сүү, сүүн бүтээгдэхүүн', 'description' => 'Сүү, тараг, ааруул.'], + 'meat' => ['name' => 'Мах', 'description' => 'Шинэ мах.'], + 'bakery' => ['name' => 'Талх, нарийн боов', 'description' => 'Өдөр бүр шинэ.'], + ], + 'addon_categories' => [ + 'bags' => ['name' => 'Савлагаа', 'description' => 'Уут сонгох.', 'max_selectable' => 1, 'is_required' => false, 'addons' => [['Цаасан уут', 'Дахин боловсруулдаг.', 200], ['Хөргөгчтэй уут', 'Хүйтэн байлгана.', 1500]]], + ], + 'products' => [ + 'milk' => ['name' => 'Сүү 1л', 'description' => 'Пастержүүлсэн үнээний сүү.', 'price' => 3800, 'category' => 'dairy', 'tags' => ['dairy'], 'recommended' => true, 'addon_categories' => ['bags']], + 'tarag' => ['name' => 'Тараг 500г', 'description' => 'Уламжлалт исгэлэн тараг.', 'price' => 4200, 'sale_price' => 3500, 'category' => 'dairy', 'tags' => ['dairy']], + 'aaruul' => ['name' => 'Ааруул 250г', 'description' => 'Гэрийн хатаасан ааруул.', 'price' => 6500, 'category' => 'dairy', 'tags' => ['dairy']], + 'mutton' => ['name' => 'Хонины мах 1кг', 'description' => 'Шинэ хонины мах.', 'price' => 18000, 'category' => 'meat', 'tags' => ['meat'], 'variants' => [['name' => 'Хэрчилт', 'required' => true, 'options' => [['Бүтэн', 0], ['Хэрчсэн', 1000]]]]], + 'beef' => ['name' => 'Үхрийн мах 1кг', 'description' => 'Ястай үхрийн мах.', 'price' => 22000, 'category' => 'meat', 'tags' => ['meat']], + 'bread' => ['name' => 'Атар талх', 'description' => 'Өглөө бүр шинээр жигнэнэ.', 'price' => 2500, 'category' => 'bakery', 'tags' => ['bread'], 'recommended' => true], + 'boortsog' => ['name' => 'Боорцог 500г', 'description' => 'Шаржигнуур боорцог.', 'price' => 6000, 'category' => 'bakery', 'tags' => ['pastry']], + ], + 'catalog' => ['name' => 'Номин Супермаркетын каталог', 'categories' => ['Сүүн бүтээгдэхүүн' => ['milk', 'tarag', 'aaruul'], 'Мах' => ['mutton', 'beef'], 'Талх' => ['bread', 'boortsog']]], + ], + $common + [ + 'key' => static::TRUCK_STORE, + 'network_category' => 'restaurants', + 'order_count' => 6, + 'name' => 'Хаан Бууз', + 'description' => 'Гар хийцийн бууз, хуушуур. Ресторан болон хоолны машинууд.', + 'email' => 'khaanbuuz@example.mn', + 'phone' => '+976 7700 0202', + 'tags' => ['restaurant', 'buuz', 'food-truck'], + 'gateway' => 'qpay', + 'options' => ['auto_accept_orders' => true, 'pickup_enabled' => true], + 'location' => ['name' => 'Хаан Бууз', 'street1' => 'Бага тойруу 12', 'city' => 'Улаанбаатар', 'postal_code' => '14201', 'country' => 'MN', 'lat' => 47.9203, 'lng' => 106.9180], + 'hours' => ['start' => '09:00', 'end' => '22:00'], + 'categories' => [ + 'buuz' => ['name' => 'Бууз', 'description' => 'Уурын бууз.'], + 'khuushuur' => ['name' => 'Хуушуур', 'description' => 'Шарсан хуушуур.'], + 'soup' => ['name' => 'Шөл', 'description' => 'Халуун шөл.'], + 'drinks' => ['name' => 'Ундаа', 'description' => 'Цай, ундаа.'], + ], + 'addon_categories' => [ + 'sides' => ['name' => 'Нэмэлт', 'description' => 'Нэмэлт хачир.', 'max_selectable' => 2, 'is_required' => false, 'addons' => [['Даршилсан ногоо', 'Байцаа, лууван.', 1500], ['Кетчуп', 'Нэмэлт соус.', 0]]], + ], + 'products' => [ + 'buuz' => ['name' => 'Бууз (10ш)', 'description' => 'Хонины махтай уурын бууз.', 'price' => 12000, 'category' => 'buuz', 'tags' => ['buuz'], 'recommended' => true, 'addon_categories' => ['sides']], + 'buuz-mix' => ['name' => 'Холимог бууз (10ш)', 'description' => 'Үхэр, хонины холимог махтай.', 'price' => 13000, 'category' => 'buuz', 'tags' => ['buuz'], 'variants' => [['name' => 'Хэмжээ', 'required' => true, 'options' => [['10ш', 0], ['20ш', 12000]]]]], + 'khuushuur' => ['name' => 'Хуушуур (4ш)', 'description' => 'Шинэхэн шарсан хуушуур.', 'price' => 10000, 'sale_price' => 8500, 'category' => 'khuushuur', 'tags' => ['khuushuur'], 'recommended' => true, 'addon_categories' => ['sides']], + 'guriltai-shul' => ['name' => 'Гурилтай шөл', 'description' => 'Гар гурилтай махан шөл.', 'price' => 11000, 'category' => 'soup', 'tags' => ['soup']], + 'suutei-tsai' => ['name' => 'Сүүтэй цай', 'description' => 'Халуун сүүтэй цай.', 'price' => 2000, 'category' => 'drinks', 'tags' => ['tea']], + // A 1,000₮ item for end-to-end payment tests on a live QPay merchant. + 'test-item' => ['name' => 'Туршилтын бараа', 'description' => 'Төлбөрийн туршилтад зориулсан 1,000₮-ийн бараа.', 'price' => 1000, 'category' => 'drinks', 'tags' => ['test']], + 'aaruul-shake' => ['name' => 'Ааруулын коктейль', 'description' => 'Ааруул, сүү, зөгийн бал.', 'price' => 6500, 'category' => 'drinks', 'tags' => ['drink']], + ], + 'catalog' => ['name' => 'Хаан Буузын цэс', 'categories' => ['Бууз' => ['buuz', 'buuz-mix'], 'Хуушуур' => ['khuushuur'], 'Шөл' => ['guriltai-shul'], 'Ундаа' => ['suutei-tsai', 'aaruul-shake', 'test-item']]], + ], + $common + [ + 'key' => 'modern-salon', + 'network_category' => 'services', + 'order_count' => 4, + 'name' => 'Модерн Салон', + 'description' => 'Үс засалт, маникюр. Цагаа урьдчилан захиална уу.', + 'email' => 'salon@example.mn', + 'phone' => '+976 7700 0203', + 'tags' => ['services', 'beauty'], + 'gateway' => null, + 'location' => ['name' => 'Модерн Салон', 'street1' => 'Сөүлийн гудамж 18', 'city' => 'Улаанбаатар', 'postal_code' => '14251', 'country' => 'MN', 'lat' => 47.9128, 'lng' => 106.9162], + 'hours' => ['start' => '10:00', 'end' => '20:00', 'closed' => ['monday']], + 'categories' => [ + 'hair' => ['name' => 'Үс засалт', 'description' => 'Эрэгтэй, эмэгтэй үс засалт.'], + 'nails' => ['name' => 'Хумс', 'description' => 'Маникюр, педикюр.'], + ], + 'products' => [ + 'haircut' => ['name' => 'Эмэгтэй үс засалт', 'description' => 'Угаалт, засалт, хатаалт.', 'price' => 35000, 'category' => 'hair', 'tags' => ['hair'], 'is_service' => true, 'is_bookable' => true, 'meta' => ['duration' => 60], 'recommended' => true], + 'barber' => ['name' => 'Эрэгтэй үс засалт', 'description' => 'Сахал засалттай.', 'price' => 25000, 'category' => 'hair', 'tags' => ['hair'], 'is_service' => true, 'is_bookable' => true, 'meta' => ['duration' => 45]], + 'manicure' => ['name' => 'Гель маникюр', 'description' => 'Өнгө сонгох боломжтой.', 'price' => 40000, 'category' => 'nails', 'tags' => ['nails'], 'is_service' => true, 'is_bookable' => true, 'meta' => ['duration' => 90]], + ], + 'catalog' => ['name' => 'Модерн Салоны үйлчилгээ', 'categories' => ['Үс засалт' => ['haircut', 'barber'], 'Хумс' => ['manicure']]], + ], + ]; + } +} diff --git a/server/seeders/Testing/NetworkSeeder.php b/server/seeders/Testing/NetworkSeeder.php index 88d0516f..c88f3b9f 100644 --- a/server/seeders/Testing/NetworkSeeder.php +++ b/server/seeders/Testing/NetworkSeeder.php @@ -13,10 +13,16 @@ * Produces a network with an order config and a sandbox Stripe gateway, network * store categories, and several member stores each with their own location, product * categories, products, catalog and (for some) their own Stripe gateway. One store is - * left without a network category to exercise the `without_category` filters. Shared + * left without a network category to exercise the `without_category` filters. Two + * service stores (tagged `services`) sell bookable services with durations. Shared * customers place orders across every store so network-level dashboards, order * listings and analytics have data. * + * Marketing: network and store promotions covering every type (percentage, fixed + * amount, free delivery, buy X get Y) and state (live, happy-hour schedule, code only, + * first order only, upcoming, ended, draft), customer segments, and sent, scheduled and + * draft campaigns. + * * Re-running the seeder purges and recreates its own fixtures only. * * php artisan db:seed --class="Fleetbase\\Storefront\\Seeders\\Testing\\NetworkSeeder" @@ -46,6 +52,7 @@ public function run(): void $this->withoutForeignKeyConstraints(fn () => $this->purgeSeedData()); $network = $this->createNetwork($company, $this->networkDefinition()); + $this->createDeliveryServiceRate($company, 'service-rate:' . static::NETWORK_KEY, $network->currency); $gateway = $this->createStripeGateway($company, $network, 'storefront:network', 'gateway:' . static::NETWORK_KEY . ':stripe'); $categories = []; @@ -65,10 +72,21 @@ public function run(): void $orders = $this->seedStoreActivity($company, $bundle, $customers, $network, $definition['order_count'] ?? 12); $orderCount += count($orders); - $bundles[] = $bundle; + $bundles[$definition['key']] = $bundle; + } + + $marketing = $this->seedMarketing($company, $network, static::NETWORK_KEY, $this->marketingDefinition(), $bundles); + foreach ($this->storeMarketingDefinitions() as $storeKey => $definition) { + if (isset($bundles[$storeKey])) { + $storeMarketing = $this->seedMarketing($company, $bundles[$storeKey]['store'], $storeKey, $definition, $bundles); + foreach ($storeMarketing as $kind => $records) { + $marketing[$kind] = array_merge($marketing[$kind], array_values($records)); + } + } } $this->command?->info(sprintf('Seeded Storefront testing network for company %s with %d stores and %d orders.', $company->public_id, count($bundles), $orderCount)); + $this->command?->info(sprintf(' Marketing: %d promotions, %d segments, %d campaigns.', count($marketing['promotions']), count($marketing['segments']), count($marketing['campaigns']))); $this->reportStorefront($network, $gateway, ' '); foreach ($bundles as $bundle) { $this->reportStorefront($bundle['store'], $bundle['gateway'], ' '); @@ -90,7 +108,7 @@ protected function networkDefinition(): array 'phone' => '+65 6100 0200', 'website' => 'https://example.test/fleetbase-marketplace', 'tags' => ['marketplace', 'testing'], - 'currency' => 'USD', + 'currency' => $this->seedCurrency(), 'timezone' => 'Asia/Singapore', 'pod_method' => 'scan', 'options' => [ @@ -107,6 +125,7 @@ protected function networkCategories(): array 'groceries' => ['name' => 'Groceries', 'description' => 'Supermarkets and fresh food.', 'icon' => 'basket-shopping', 'icon_color' => '#16a34a', 'tags' => ['food', 'fresh']], 'restaurants' => ['name' => 'Restaurants', 'description' => 'Prepared meals for delivery and pickup.', 'icon' => 'utensils', 'icon_color' => '#ea580c', 'tags' => ['food', 'meals']], 'health' => ['name' => 'Health & Beauty', 'description' => 'Pharmacies and personal care.', 'icon' => 'heart-pulse', 'icon_color' => '#2563eb', 'tags' => ['pharmacy']], + 'services' => ['name' => 'Home & Wellness', 'description' => 'Book cleaners, stylists and therapists.', 'icon' => 'calendar-check', 'icon_color' => '#7c3aed', 'tags' => ['services']], ]; } @@ -247,6 +266,116 @@ protected function storeDefinitions(): array ], 'catalog' => ['name' => 'Marina Flowers Catalog', 'categories' => ['Bouquets' => ['rose-bouquet', 'mixed-bouquet']]], ], + [ + 'key' => 'lion-city-cleaning', + 'network_category' => 'services', + 'order_count' => 5, + 'name' => 'Lion City Cleaning', + 'description' => 'Home cleaning by vetted cleaners. Book a date and start time.', + 'email' => 'cleaning@example.test', + 'phone' => '+65 6100 0306', + 'tags' => ['services', 'cleaning', 'home'], + 'gateway' => 'stripe', + 'location' => ['name' => 'Lion City Cleaning', 'street1' => '7 Raffles Place', 'city' => 'Singapore', 'postal_code' => '048616', 'country' => 'SG', 'lat' => 1.2840, 'lng' => 103.8515], + 'hours' => ['start' => '08:00', 'end' => '20:00', 'closed' => ['sunday']], + 'categories' => [ + 'cleaning' => ['name' => 'Home Cleaning', 'description' => 'Regular and deep cleans.'], + 'specialty' => ['name' => 'Specialty', 'description' => 'Appliances, windows and upholstery.'], + ], + 'addon_categories' => [ + 'extras' => ['name' => 'Extras', 'description' => 'Add to your clean.', 'max_selectable' => 3, 'is_required' => false, 'addons' => [['Inside the Fridge', 'Empty, wipe and restock.', 1500], ['Inside the Oven', 'Degrease racks and walls.', 2000], ['Ironing (1 hour)', 'Shirts and linens.', 2500]]], + ], + 'products' => [ + 'standard-clean' => ['name' => 'Standard Home Clean', 'description' => 'Dusting, floors, kitchen and bathrooms.', 'price' => 6500, 'category' => 'cleaning', 'tags' => ['cleaning'], 'recommended' => true, 'is_service' => true, 'is_bookable' => true, 'can_pickup' => false, 'meta' => ['duration' => 180], 'variants' => [['name' => 'Home Size', 'required' => true, 'multiselect' => false, 'options' => [['Studio / 1 bedroom', 0], ['2 bedrooms', 2000], ['3+ bedrooms', 4000]]]], 'addon_categories' => ['extras']], + 'deep-clean' => ['name' => 'Deep Clean', 'description' => 'Top to bottom, including inside cabinets.', 'price' => 14500, 'category' => 'cleaning', 'tags' => ['cleaning'], 'is_service' => true, 'is_bookable' => true, 'can_pickup' => false, 'meta' => ['duration' => 360], 'addon_categories' => ['extras']], + 'aircon-service' => ['name' => 'Aircon Servicing', 'description' => 'Filter wash and coil check, per unit.', 'price' => 3500, 'category' => 'specialty', 'tags' => ['aircon'], 'is_service' => true, 'is_bookable' => true, 'can_pickup' => false, 'meta' => ['duration' => 45]], + 'sofa-clean' => ['name' => 'Sofa Shampoo', 'description' => 'Fabric sofa, up to 3 seats.', 'price' => 8900, 'sale_price' => 7500, 'category' => 'specialty', 'tags' => ['upholstery'], 'is_service' => true, 'is_bookable' => true, 'can_pickup' => false, 'meta' => ['duration' => 90]], + ], + 'catalog' => ['name' => 'Lion City Cleaning Services', 'categories' => ['Home Cleaning' => ['standard-clean', 'deep-clean'], 'Specialty' => ['aircon-service', 'sofa-clean']]], + ], + [ + 'key' => 'tiong-bahru-wellness', + 'network_category' => 'services', + 'order_count' => 5, + 'name' => 'Tiong Bahru Wellness', + 'description' => 'Hair, nails and massage. Book a time at the studio.', + 'email' => 'wellness@example.test', + 'phone' => '+65 6100 0307', + 'tags' => ['services', 'salon', 'spa'], + 'gateway' => null, + 'location' => ['name' => 'Tiong Bahru Wellness', 'street1' => '55 Tiong Bahru Road', 'city' => 'Singapore', 'postal_code' => '160055', 'country' => 'SG', 'lat' => 1.2860, 'lng' => 103.8302], + 'hours' => ['start' => '10:00', 'end' => '21:00', 'closed' => ['monday']], + 'categories' => [ + 'hair' => ['name' => 'Hair', 'description' => 'Cuts and colour.'], + 'massage' => ['name' => 'Massage', 'description' => 'Relaxation and deep tissue.'], + 'nails' => ['name' => 'Nails', 'description' => 'Manicures and pedicures.'], + ], + 'products' => [ + 'haircut' => ['name' => 'Haircut & Blow Dry', 'description' => 'Consultation, wash, cut and style.', 'price' => 4800, 'category' => 'hair', 'tags' => ['hair'], 'recommended' => true, 'is_service' => true, 'is_bookable' => true, 'meta' => ['duration' => 60], 'variants' => [['name' => 'Stylist', 'required' => true, 'multiselect' => false, 'options' => [['Stylist', 0], ['Senior Stylist', 1500]]]]], + 'hair-colour' => ['name' => 'Full Colour', 'description' => 'Single process colour with gloss.', 'price' => 12000, 'category' => 'hair', 'tags' => ['colour'], 'is_service' => true, 'is_bookable' => true, 'meta' => ['duration' => 120]], + 'swedish-60' => ['name' => 'Swedish Massage 60 min', 'description' => 'Full body, light to medium pressure.', 'price' => 9800, 'category' => 'massage', 'tags' => ['massage'], 'recommended' => true, 'is_service' => true, 'is_bookable' => true, 'meta' => ['duration' => 60]], + 'deep-tissue-90' => ['name' => 'Deep Tissue Massage 90 min', 'description' => 'Firm pressure for tight muscles.', 'price' => 14800, 'category' => 'massage', 'tags' => ['massage'], 'is_service' => true, 'is_bookable' => true, 'meta' => ['duration' => 90]], + 'gel-manicure' => ['name' => 'Gel Manicure', 'description' => 'Shape, cuticle care and gel colour.', 'price' => 4500, 'category' => 'nails', 'tags' => ['nails'], 'is_service' => true, 'is_bookable' => true, 'meta' => ['duration' => 45]], + ], + 'catalog' => ['name' => 'Tiong Bahru Wellness Menu', 'categories' => ['Hair' => ['haircut', 'hair-colour'], 'Massage' => ['swedish-60', 'deep-tissue-90'], 'Nails' => ['gel-manicure']]], + ], + ]; + } + + /** + * Network-wide promotions, segments and campaigns (owned by the network). + */ + protected function marketingDefinition(): array + { + return [ + 'promotions' => [ + 'welcome' => ['name' => 'Welcome to Fleetbase Marketplace', 'description' => '15% off your first order, up to S$10.', 'type' => 'percentage', 'value' => 15, 'max_discount_amount' => 1000, 'min_subtotal' => 2000, 'first_order_only' => true, 'code' => 'WELCOME15', 'usage_limit_per_customer' => 1, 'starts_in_days' => -30, 'ends_in_days' => 60], + 'free-delivery' => ['name' => 'Free delivery over S$40', 'description' => 'Delivery is on us when your basket is S$40 or more.', 'type' => 'free_delivery', 'min_subtotal' => 4000, 'starts_in_days' => -14, 'ends_in_days' => 30, 'priority' => 5], + 'weekend-five' => ['name' => 'S$5 off weekend orders', 'description' => 'Every Saturday and Sunday, S$5 off orders of S$25 or more.', 'type' => 'fixed_amount', 'value' => 500, 'min_subtotal' => 2500, 'schedule' => [['days' => [6, 7], 'start' => '00:00', 'end' => '23:59']], 'starts_in_days' => -7, 'ends_in_days' => 45], + 'services-launch' => ['name' => 'Home & Wellness launch', 'description' => '20% off any booked service.', 'type' => 'percentage', 'value' => 20, 'max_discount_amount' => 3000, 'applies_to' => ['stores' => ['lion-city-cleaning', 'tiong-bahru-wellness']], 'starts_in_days' => 3, 'ends_in_days' => 33], + 'spring-sale' => ['name' => 'Spring sale', 'description' => '10% off everything (ended).', 'type' => 'percentage', 'value' => 10, 'starts_in_days' => -40, 'ends_in_days' => -10], + 'vip-preview' => ['name' => 'VIP preview (draft)', 'description' => 'Not published yet.', 'type' => 'percentage', 'value' => 25, 'status' => 'draft', 'is_public' => false, 'code' => 'VIPPREVIEW'], + ], + 'segments' => [ + 'loyal' => ['name' => 'Loyal customers', 'description' => 'Three or more orders.', 'rules' => ['min_orders' => 3]], + 'recent' => ['name' => 'Ordered this month', 'description' => 'At least one order in the last 30 days.', 'rules' => ['ordered_within_days' => 30]], + 'lapsed' => ['name' => 'Lapsed customers', 'description' => 'Ordered before, but not in the last 14 days.', 'rules' => ['not_ordered_within_days' => 14]], + 'never' => ['name' => 'Never ordered', 'description' => 'Signed up but no orders yet.', 'rules' => ['max_orders' => 0]], + 'push-ready' => ['name' => 'Push enabled', 'description' => 'Customers with a registered push device.', 'rules' => ['has_push_device' => true]], + ], + 'campaigns' => [ + 'welcome-sent' => ['name' => 'Welcome offer', 'title' => '15% off your first order', 'body' => 'Use WELCOME15 at checkout. Ends soon.', 'status' => 'sent', 'segment' => 'never', 'promotion' => 'welcome', 'action' => ['type' => 'promotion'], 'sent_days_ago' => 3, 'stats' => ['targeted' => 0, 'batches' => 0]], + 'delivery-sent' => ['name' => 'Free delivery week', 'title' => 'Free delivery over S$40', 'body' => 'Stock up this week, delivery is on us.', 'status' => 'sent', 'segment' => 'loyal', 'promotion' => 'free-delivery', 'action' => ['type' => 'promotion'], 'sent_days_ago' => 1, 'stats' => ['targeted' => 12, 'batches' => 1]], + 'services-soon' => ['name' => 'Home & Wellness launch', 'title' => 'Book a clean or a massage', 'body' => 'New on Fleetbase Marketplace: 20% off booked services.', 'status' => 'scheduled', 'segment' => 'recent', 'promotion' => 'services-launch', 'action' => ['type' => 'store', 'ref' => 'lion-city-cleaning'], 'send_in_days' => 14], + 'winback-draft' => ['name' => 'We miss you (draft)', 'title' => 'Come back for S$5 off', 'body' => 'Here is S$5 off your next weekend order.', 'status' => 'draft', 'segment' => 'lapsed', 'promotion' => 'weekend-five'], + ], + ]; + } + + /** + * Promotions and campaigns owned by individual member stores, by store key. + */ + protected function storeMarketingDefinitions(): array + { + return [ + 'orchard-grocers' => [ + 'promotions' => [ + 'bakery-happy-hour' => ['name' => 'Bakery happy hour', 'description' => '30% off bakery, weekdays 5 to 8 pm.', 'type' => 'percentage', 'value' => 30, 'applies_to' => ['categories' => ['orchard-grocers:bakery']], 'schedule' => [['days' => [1, 2, 3, 4, 5], 'start' => '17:00', 'end' => '20:00']], 'starts_in_days' => -10, 'ends_in_days' => 50], + ], + 'campaigns' => [ + 'bakery-sent' => ['name' => 'Bakery happy hour', 'title' => '30% off fresh bread after 5', 'body' => 'Sourdough and croissants, weekdays 5 to 8 pm.', 'status' => 'sent', 'promotion' => 'bakery-happy-hour', 'action' => ['type' => 'product', 'ref' => 'orchard-grocers:sourdough'], 'sent_days_ago' => 2, 'stats' => ['targeted' => 8, 'batches' => 1]], + ], + ], + 'rochor-noodle-house' => [ + 'promotions' => [ + 'dumpling-bogo' => ['name' => 'Buy 2 dumplings, get 1 free', 'description' => 'On any dumpling plate.', 'type' => 'bogo', 'bogo_config' => ['buy_quantity' => 2, 'get_quantity' => 1, 'discount_percent' => 100], 'applies_to' => ['products' => ['rochor-noodle-house:pork-dumplings', 'rochor-noodle-house:veg-dumplings']], 'starts_in_days' => -5, 'ends_in_days' => 25], + ], + ], + 'lion-city-cleaning' => [ + 'promotions' => [ + 'first-clean' => ['name' => 'S$20 off your first clean', 'description' => 'For new customers, on any home clean.', 'type' => 'fixed_amount', 'value' => 2000, 'min_subtotal' => 6000, 'first_order_only' => true, 'code' => 'FIRSTCLEAN', 'applies_to' => ['categories' => ['lion-city-cleaning:cleaning']], 'starts_in_days' => -20, 'ends_in_days' => 70], + ], + ], ]; } } diff --git a/server/seeders/Testing/StoreSeeder.php b/server/seeders/Testing/StoreSeeder.php index f2f18fff..90b89208 100644 --- a/server/seeders/Testing/StoreSeeder.php +++ b/server/seeders/Testing/StoreSeeder.php @@ -12,7 +12,8 @@ * Produces a store with an order config, a store location with opening hours, * a sandbox Stripe gateway, product categories, products with variants and addons, * a published catalog, customers, an open cart, a pending checkout, a month of - * captured orders (cash and Stripe, delivery and pickup) and reviews. + * captured orders (cash and Stripe, delivery and pickup), reviews, and promotions, + * customer segments and campaigns in several states. * * Re-running the seeder purges and recreates its own fixtures only. * @@ -43,10 +44,13 @@ public function run(): void $this->withoutForeignKeyConstraints(fn () => $this->purgeSeedData()); $bundle = $this->seedStore($company, $this->storeDefinition()); + $this->createDeliveryServiceRate($company, 'service-rate:' . static::STORE_KEY, $bundle['store']->currency); $customers = $this->seedCustomers($company, $this->customerFixtures()); $orders = $this->seedStoreActivity($company, $bundle, $customers, null, 30); + $marketing = $this->seedMarketing($company, $bundle['store'], static::STORE_KEY, $this->marketingDefinition(), [static::STORE_KEY => $bundle]); $this->command?->info(sprintf('Seeded Storefront testing store for company %s with %d products and %d orders.', $company->public_id, count($bundle['products']), count($orders))); + $this->command?->info(sprintf(' Marketing: %d promotions, %d segments, %d campaigns.', count($marketing['promotions']), count($marketing['segments']), count($marketing['campaigns']))); $this->reportStorefront($bundle['store'], $bundle['gateway'], ' '); } @@ -55,6 +59,18 @@ public function purgeSeedData(): void $this->purgeStorefrontFixtures(); } + /** + * The network seeder uses the same customer fixtures in the same company, so this + * store's customers get their own emails (`ava.chen+market@example.test`) and phones + * (`+65 9101 …`). + */ + protected function customerIdentity(array $fixture): array + { + [$name, $email, $phone] = $fixture; + + return [$name, str_replace('@', '+market@', $email), str_replace('+65 9100 ', '+65 9101 ', $phone)]; + } + protected function storeDefinition(): array { return [ @@ -65,7 +81,7 @@ protected function storeDefinition(): array 'phone' => '+65 6100 0100', 'website' => 'https://example.test/fleetbase-market', 'tags' => ['groceries', 'local', 'testing'], - 'currency' => 'USD', + 'currency' => $this->seedCurrency(), 'timezone' => 'Asia/Singapore', 'pod_method' => 'scan', 'gateway' => 'stripe', @@ -214,4 +230,30 @@ protected function storeDefinition(): array ], ]; } + + /** + * The store's own promotions, segments and campaigns. + */ + protected function marketingDefinition(): array + { + $key = static::STORE_KEY; + + return [ + 'promotions' => [ + 'coffee-hour' => ['name' => 'Coffee hour', 'description' => '20% off beverages, every morning 7 to 10.', 'type' => 'percentage', 'value' => 20, 'applies_to' => ['categories' => [$key . ':beverages']], 'schedule' => [['days' => [1, 2, 3, 4, 5, 6, 7], 'start' => '07:00', 'end' => '10:00']], 'starts_in_days' => -14, 'ends_in_days' => 60], + 'market-five' => ['name' => 'S$5 off S$30', 'description' => 'Use MARKET5 on orders of S$30 or more.', 'type' => 'fixed_amount', 'value' => 500, 'min_subtotal' => 3000, 'code' => 'MARKET5', 'usage_limit_per_customer' => 3, 'starts_in_days' => -7, 'ends_in_days' => 30], + 'free-delivery' => ['name' => 'Free delivery for first orders', 'description' => 'Your first delivery is free.', 'type' => 'free_delivery', 'first_order_only' => true, 'starts_in_days' => -30, 'ends_in_days' => 90], + 'harvest' => ['name' => 'Harvest week', 'description' => '15% off fresh produce (coming soon).', 'type' => 'percentage', 'value' => 15, 'applies_to' => ['categories' => [$key . ':produce']], 'starts_in_days' => 5, 'ends_in_days' => 12], + ], + 'segments' => [ + 'regulars' => ['name' => 'Regulars', 'description' => 'Five or more orders.', 'rules' => ['min_orders' => 5]], + 'lapsed' => ['name' => 'Lapsed', 'description' => 'No order in the last 21 days.', 'rules' => ['not_ordered_within_days' => 21]], + ], + 'campaigns' => [ + 'coffee-sent' => ['name' => 'Coffee hour', 'title' => '20% off coffee before 10', 'body' => 'Cold brew and lattes, every morning.', 'status' => 'sent', 'segment' => 'regulars', 'promotion' => 'coffee-hour', 'action' => ['type' => 'promotion'], 'sent_days_ago' => 2, 'stats' => ['targeted' => 6, 'batches' => 1]], + 'harvest-soon' => ['name' => 'Harvest week', 'title' => 'Harvest week starts soon', 'body' => '15% off fresh produce all week.', 'status' => 'scheduled', 'promotion' => 'harvest', 'action' => ['type' => 'promotion'], 'send_in_days' => 5], + 'winback-draft' => ['name' => 'Win back (draft)', 'title' => 'S$5 off your next order', 'body' => 'Use MARKET5 on orders of S$30 or more.', 'status' => 'draft', 'segment' => 'lapsed', 'promotion' => 'market-five'], + ], + ]; + } } diff --git a/server/src/Console/Commands/PurgeExpiredCarts.php b/server/src/Console/Commands/PurgeExpiredCarts.php index ccc2db22..d7a9fafb 100644 --- a/server/src/Console/Commands/PurgeExpiredCarts.php +++ b/server/src/Console/Commands/PurgeExpiredCarts.php @@ -19,7 +19,7 @@ class PurgeExpiredCarts extends Command * * @var string */ - protected $description = 'Permanently delete all expired carts from the database'; + protected $description = 'Permanently delete expired carts that are still open and empty'; /** * Execute the console command. @@ -34,8 +34,18 @@ public function handle() $schema->disableForeignKeyConstraints(); try { + // Only expired carts that are still open and hold nothing. Checked out and + // cleared carts, and abandoned carts with items, are the record of what was + // bought or wanted, so they are kept. $dbDeletedCount = $dbConnection->table('carts') ->where('expires_at', '<', now()) + ->whereNull('checkout_uuid') + ->where(function ($query) { + $query->whereNull('status')->orWhere('status', 'open'); + }) + ->where(function ($query) { + $query->whereNull('items')->orWhereIn('items', ['[]', '{}', '', 'null']); + }) ->delete(); } finally { $schema->enableForeignKeyConstraints(); diff --git a/server/src/Http/Controllers/OrderController.php b/server/src/Http/Controllers/OrderController.php index 09a6d51c..f1627968 100644 --- a/server/src/Http/Controllers/OrderController.php +++ b/server/src/Http/Controllers/OrderController.php @@ -76,6 +76,11 @@ protected function createAcceptedActivity($orderConfig) protected function notifyOrderAccepted(Order $order): void { + // A booking's confirmation is announced as its activity ("Booking confirmed"). + if (Storefront::isBookingOrder($order)) { + return; + } + $order->customer->notify(new StorefrontOrderAccepted($order)); } @@ -172,6 +177,13 @@ public function markOrderAsReady(Request $request) // Patch order config $this->patchOrderConfig($order); + // A booking at the store starts when the customer comes in; nobody is dispatched. + if (Storefront::isBookingOrder($order) && $order->isMeta('is_pickup')) { + $order->updateStatus('in_progress'); + + return $this->orderResponse($order); + } + if ($order->isMeta('is_pickup')) { $order->updateStatus('pickup_ready'); @@ -188,9 +200,12 @@ public function markOrderAsReady(Request $request) $order->assignDriver($driver); } - // Dispatch the order and move Storefront delivery orders into preparation. + // Dispatch the order and move Storefront delivery orders into preparation. A booking + // is dispatched to its provider; there is nothing to prepare. $order->dispatchWithActivity(); - $order->updateStatus('preparing'); + if (!Storefront::isBookingOrder($order)) { + $order->updateStatus('preparing'); + } return $this->orderResponse($order); } diff --git a/server/src/Http/Controllers/StoreController.php b/server/src/Http/Controllers/StoreController.php index 0b67731e..91c65728 100644 --- a/server/src/Http/Controllers/StoreController.php +++ b/server/src/Http/Controllers/StoreController.php @@ -3,6 +3,7 @@ namespace Fleetbase\Storefront\Http\Controllers; use Fleetbase\Storefront\Models\Store; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Http\Request; class StoreController extends StorefrontController @@ -14,6 +15,19 @@ class StoreController extends StorefrontController */ public $resource = 'store'; + /** + * Eager-load what the store resource always serializes, so listing stores + * does not run logo, backdrop and rating queries for every store. + */ + public function onQueryRecord(Builder $builder, Request $request) + { + $builder->with(['logo', 'backdrop'])->withAvg('reviews', 'rating'); + + if ($request->filled('network') && ($request->has('with_category') || $request->inArray('with', 'category'))) { + $builder->with('networks'); + } + } + public function allStores(Request $request) { $stores = Store::select(['uuid', 'name', 'description', 'created_at']) diff --git a/server/src/Http/Controllers/v1/CartController.php b/server/src/Http/Controllers/v1/CartController.php index 165d767c..3d77f8f2 100644 --- a/server/src/Http/Controllers/v1/CartController.php +++ b/server/src/Http/Controllers/v1/CartController.php @@ -127,11 +127,12 @@ public function remove(?string $cartId, ?string $cartItemId, Request $request) */ public function empty(string $cartId) { + // Emptying closes the cart as cleared, keeping its items as a record of what it + // held, and continues with the device's open cart. A cart already checked out or + // cleared is never touched: retrieveCart() resolves its id to the device's open cart. $cart = $this->retrieveCart($cartId); - $cart->empty(); - - return new StorefrontCart($cart); + return new StorefrontCart($cart->clear()); } /** diff --git a/server/src/Http/Controllers/v1/CheckoutController.php b/server/src/Http/Controllers/v1/CheckoutController.php index df5aeddb..684eccd4 100644 --- a/server/src/Http/Controllers/v1/CheckoutController.php +++ b/server/src/Http/Controllers/v1/CheckoutController.php @@ -18,11 +18,13 @@ use Fleetbase\Storefront\Http\Requests\InitializeCheckoutRequest; use Fleetbase\Storefront\Models\Cart; use Fleetbase\Storefront\Models\Checkout; +use Fleetbase\Storefront\Http\Middleware\SetStorefrontSession; use Fleetbase\Storefront\Models\Customer; use Fleetbase\Storefront\Models\FoodTruck; use Fleetbase\Storefront\Models\Gateway; use Fleetbase\Storefront\Models\Network; use Fleetbase\Storefront\Models\Product; +use Illuminate\Support\Carbon; use Fleetbase\Storefront\Models\Store; use Fleetbase\Storefront\Models\StoreLocation; use Fleetbase\Storefront\Promotions\PromotionContext; @@ -32,6 +34,7 @@ use Fleetbase\Storefront\Promotions\PromotionUnavailableException; use Fleetbase\Storefront\Support\QPay; use Fleetbase\Storefront\Support\Storefront; +use Fleetbase\Storefront\Support\StorefrontSocket; use Fleetbase\Storefront\Support\StripeUtils; use Fleetbase\Support\SocketCluster\SocketClusterService; use Illuminate\Http\JsonResponse; @@ -385,7 +388,7 @@ public static function initializeCashCheckout(Contact $customer, Gateway $gatewa // GET /checkouts/status needs BOTH, and only initializeQPayCheckout was returning // the id — so a cash or card client could never reach its own checkout's status. // The checkout is discarded instead if one of its promotions ran out meanwhile. - return static::reservePromotions($checkout, $checkoutOptions, $customer) ?? response()->json([ + return static::reservePromotions($checkout, $checkoutOptions, $customer) ?? static::checkoutResponse($checkout, [ 'checkout' => $checkout->public_id, 'token' => $checkout->token, ]); @@ -486,7 +489,7 @@ public static function initializeStripeCheckout(Contact $customer, Gateway $gate // See initializeCheckout: `checkout` is the chkt_* public id GET /checkouts/status // requires alongside the token, and nothing but the QPay path used to return it. - return static::reservePromotions($checkout, $checkoutOptions, $customer) ?? response()->json([ + return static::reservePromotions($checkout, $checkoutOptions, $customer) ?? static::checkoutResponse($checkout, [ 'paymentIntent' => $paymentIntent->id, 'clientSecret' => $paymentIntent->client_secret, 'ephemeralKey' => $ephemeralKey->secret, @@ -699,8 +702,7 @@ public function updateStripePaymentIntent(Request $request) return response()->apiError('Failed to create ephemeral key: ' . $e->getMessage()); } - // Create a new checkout token - $checkout = Checkout::create([ + $attributes = [ 'company_uuid' => session('company'), 'store_uuid' => session('storefront_store'), 'network_uuid' => session('storefront_network'), @@ -714,11 +716,26 @@ public function updateStripePaymentIntent(Request $request) 'is_pickup' => $isPickup, 'options' => $checkoutOptions, 'cart_state' => $cart->toArray(), - ]); + ]; + + // Capture verifies the payment against the checkout's PaymentIntent, and a + // PaymentIntent belongs to one checkout, so update the checkout that started it + // rather than adding an unlinked one. + $checkout = Checkout::where('stripe_payment_intent_id', $paymentIntent->id)->first(); + if ($checkout && $checkout->owner_uuid !== $customer->uuid) { + return response()->apiError('PaymentIntent belongs to another checkout.', 422); + } + if ($checkout) { + // Its promotion uses are reserved again below at the new price. + PromotionRedemptions::releaseFor($checkout); + $checkout->update($attributes); + } else { + $checkout = Checkout::create([...$attributes, 'stripe_payment_intent_id' => $paymentIntent->id]); + } // Return JSON response with updated PaymentIntent and ephemeral key. `checkout` is // the chkt_* public id GET /checkouts/status requires alongside the token. - return static::reservePromotions($checkout, $checkoutOptions, $customer) ?? response()->json([ + return static::reservePromotions($checkout, $checkoutOptions, $customer) ?? static::checkoutResponse($checkout, [ 'paymentIntent' => $paymentIntent->id, 'clientSecret' => $paymentIntent->client_secret, 'ephemeralKey' => $ephemeralKey->secret, @@ -789,13 +806,24 @@ public static function initializeQPayCheckout(Contact $customer, Gateway $gatewa // Create invoice description $taxType = '1'; // Start with VAT required - $ebarimtInvoiceCode = $gateway->sandbox ? 'TEST_INVOICE' : $gateway->config?->ebarimt_invoice_id ?? null; + // Sandbox e-barimt invoices use QPay's TEST_EB_INVOICE code (QPay API v2, invoice_create_ebarimt). + $ebarimtInvoiceCode = $gateway->sandbox ? 'TEST_EB_INVOICE' : $gateway->config?->ebarimt_invoice_id ?? null; $invoiceAmount = $amount; $invoiceCode = $gateway->sandbox ? 'TEST_INVOICE' : $gateway->config?->invoice_id ?? null; $invoiceDescription = $about->name . ' cart checkout'; - $invoiceReceiverCode = 'CITIZEN'; - $senderInvoiceNo = $checkout->public_id; + // The customer's own unique code (QPay: "unique number of the customer receiving the + // invoice"). The e-barimt receiver type is sent separately, with ebarimt_v3/create. + $invoiceReceiverCode = static::qpayCode($customer->public_id); + // Unique per checkout, without special characters (QPay: sender_invoice_no). + $senderInvoiceNo = static::qpayCode($checkout->public_id); $districtCode = $gateway->config?->district_code ?? null; + // QPay requires district_code on e-barimt invoices (QPay API v2, invoice_create_ebarimt): + // the 4-digit code of where the business operates (district + sub-district, see the + // district_code list), set in the gateway settings. QPay's sandbox accepts invoices + // without it, so a live gateway missing it is reported rather than guessed. + if ($ebarimtInvoiceCode && !$districtCode && !$gateway->sandbox) { + Log::warning('[QPAY]: e-barimt invoice without a district code; set district_code in the QPay gateway settings', ['gateway' => $gateway->public_id]); + } $invoiceReceiverData = Utils::filterArray([ 'register' => $ebarimtRegistationNumber, 'name' => $customer->name, @@ -839,10 +867,14 @@ public static function initializeQPayCheckout(Contact $customer, Gateway $gatewa $invoice = $qpay->createSimpleInvoice($invoiceAmount, $invoiceCode, $invoiceDescription, $invoiceReceiverCode, $senderInvoiceNo); } - // Update checkout with invoice id + // Update checkout with invoice id, and the amount the invoice asks for so a payment + // can be checked against it before the order is created. $checkout->updateOption('qpay_invoice_id', data_get($invoice, 'invoice_id')); + if (data_get($invoice, 'invoice_id')) { + $checkout->updateOption('qpay_invoice_amount', $ebarimtInvoiceCode ? QPay::linesTotal($lines) : (float) $invoiceAmount); + } - return static::reservePromotions($checkout, $checkoutOptions, $customer) ?? response()->json([ + return static::reservePromotions($checkout, $checkoutOptions, $customer) ?? static::checkoutResponse($checkout, [ 'invoice' => $invoice, 'checkout' => $checkout->public_id, 'token' => $checkout->token, @@ -861,7 +893,7 @@ public static function initializeQPayCheckout(Contact $customer, Gateway $gatewa * response for either success or error scenarios. * - Initializes a QPay instance with the gateway configuration and sets the authentication token. * - Retrieves the invoice ID from the checkout options and performs a payment check using QPay's API. - * - Publishes the payment data or error response to the SocketCluster channel. + * - Publishes the checkout status, its order and any error to the checkout's realtime channel. * * Depending on the 'respond' flag from the request, the method returns a JSON response * or completes the processing without returning data. @@ -881,8 +913,13 @@ public function captureQPayCallback(Request $request) $shouldRespond = $request->boolean('respond'); $testScenario = $request->input('test'); // Expected: 'success' or 'error' + // QPay calls this URL (GET) when a payment is made and requires the reply to be + // HTTP 200 with the body SUCCESS, in no other format. `respond=1` (manual checks) + // gets the details as JSON instead. + $reply = fn (array $data) => $shouldRespond ? response()->json($data) : response('SUCCESS', 200)->header('Content-Type', 'text/plain'); + if (!$checkoutId) { - return response()->json([ + return $reply([ 'error' => 'CHECKOUT_ID_MISSING', 'checkout' => null, 'payment' => null, @@ -891,7 +928,7 @@ public function captureQPayCallback(Request $request) $checkout = Checkout::where('public_id', $checkoutId)->first(); if (!$checkout) { - return response()->json([ + return $reply([ 'error' => 'CHECKOUT_SESSION_NOT_FOUND', 'checkout' => null, 'payment' => null, @@ -900,7 +937,7 @@ public function captureQPayCallback(Request $request) $gateway = Gateway::where('uuid', $checkout->gateway_uuid)->first(); if (!$gateway) { - return response()->json([ + return $reply([ 'error' => 'GATEWAY_NOT_CONFIGURED', 'checkout' => $checkout->public_id, 'payment' => null, @@ -925,9 +962,9 @@ public function captureQPayCallback(Request $request) ]; } - SocketClusterService::publish('checkout.' . $checkout->public_id, $data); + static::publishCheckoutUpdate($checkout, $testScenario === 'success', $data['error']); - return $shouldRespond ? response()->json($data) : response()->json(); + return $reply($data); } // Create the QPay instance. @@ -943,45 +980,47 @@ public function captureQPayCallback(Request $request) if (!$invoiceId) { Log::error("Missing QPay invoice ID for checkout: {$checkout->public_id}"); - return response()->json([ + return $reply([ 'error' => 'MISSING_INVOICE_ID', 'checkout' => $checkout->public_id, 'payment' => null, ]); } + // The order is created only for a payment QPay reports as PAID, covering the + // invoice amount. NEW, FAILED, PARTIAL and REFUNDED payments create nothing. $paymentCheck = $qpay->paymentCheck($invoiceId); - if (!$paymentCheck || empty($paymentCheck->count) || $paymentCheck->count < 1) { - return response()->json([ - 'error' => 'PAYMENT_NOTFOUND', + $payment = static::paidQPayPayment($paymentCheck, $checkout); + if (!$payment) { + return $reply([ + 'error' => 'PAYMENT_NOT_PAID', 'checkout' => $checkout->public_id, 'payment' => null, ]); } - $payment = data_get($paymentCheck, 'rows.0'); - - if ($payment) { - // Create order from payment using reusable gateway-agnostic method - $transactionDetails = [ - 'transaction_id' => $payment->payment_id, - 'payment_status' => 'PAID', - 'payment_wallet' => $payment->payment_wallet ?? 'QPay', - ]; + // Record the payment on the checkout and tell the app at once; creating the order + // takes a moment, and a second update follows with the order. + static::recordQPayPayment($checkout, $payment); + static::publishCheckoutUpdate($checkout, true); - $this->createOrderFromCheckout($checkout, $transactionDetails); - $checkout->refresh(); + // Create order from payment using reusable gateway-agnostic method + $transactionDetails = [ + 'transaction_id' => $payment->payment_id, + 'payment_status' => $payment->payment_status, + 'payment_wallet' => $payment->payment_wallet ?? 'QPay', + ]; - $data = [ - 'checkout' => $checkout->public_id, - 'payment' => (array) $payment, - 'error' => null, - ]; + $this->createOrderFromCheckout($checkout, $transactionDetails); + $checkout->refresh(); - SocketClusterService::publish('checkout.' . $checkout->public_id, $data); + static::publishCheckoutUpdate($checkout, true); - return $shouldRespond ? response()->json($data) : response()->json(); - } + return $reply([ + 'checkout' => $checkout->public_id, + 'payment' => (array) $payment, + 'error' => null, + ]); } catch (\Exception $e) { Log::error('[QPAY CHECKOUT ERROR]: ' . $e->getMessage(), ['checkout' => $checkout->toArray()]); if ($shouldRespond) { @@ -989,7 +1028,96 @@ public function captureQPayCallback(Request $request) } } - return response()->json(); + return $reply(['checkout' => $checkout->public_id, 'payment' => null, 'error' => null]); + } + + /** + * Make the checkout's storefront (its network, else its store) the storefront of the + * current request, exactly as SetStorefrontSession does for the storefront API. Order + * creation reads the storefront from there, and requests from a payment provider (QPay's + * callback) arrive without one. Always the checkout's own, so an order is never created + * under another storefront's settings. + */ + protected static function useCheckoutStorefront(Checkout $checkout): void + { + $owner = $checkout->network_uuid + ? Network::select(['key'])->where('uuid', $checkout->network_uuid)->first() + : ($checkout->store_uuid ? Store::select(['key'])->where('uuid', $checkout->store_uuid)->first() : null); + + if ($owner && $owner->key) { + app(SetStorefrontSession::class)->setKey($owner->key); + } + } + + /** + * Save a verified QPay payment on the checkout (once) and return its summary, so the + * app can be told it was paid without asking QPay again. + */ + protected static function recordQPayPayment(Checkout $checkout, object $payment): array + { + $existing = $checkout->getOption('qpay_payment'); + if ($existing) { + return (array) $existing; + } + + $summary = [ + 'payment_id' => $payment->payment_id ?? null, + 'payment_status' => $payment->payment_status ?? 'PAID', + 'payment_amount' => $payment->payment_amount ?? null, + 'payment_currency' => $payment->payment_currency ?? $checkout->currency, + 'payment_date' => $payment->payment_date ?? null, + 'payment_wallet' => $payment->payment_wallet ?? 'QPay', + 'recorded_at' => now()->toIso8601String(), + ]; + $checkout->updateOption('qpay_payment', $summary); + + return $summary; + } + + /** + * The payment that pays a checkout's QPay invoice, or null. + * + * QPay's payment/check returns `count`, `paid_amount` and `rows`, each row with a + * `payment_status` of NEW, FAILED, PAID, PARTIAL or REFUNDED. A checkout is paid only + * by a PAID row, and only when the amount paid covers the invoice amount recorded when + * the invoice was created (older checkouts without it are checked by status only). + */ + protected static function paidQPayPayment($paymentCheck, Checkout $checkout): ?object + { + $rows = collect(data_get($paymentCheck, 'rows', [])); + $paid = $rows->first(fn ($row) => data_get($row, 'payment_status') === 'PAID'); + if (!$paid) { + return null; + } + + $expected = $checkout->getOption('qpay_invoice_amount'); + if ($expected !== null) { + $paidAmount = data_get($paymentCheck, 'paid_amount'); + if ($paidAmount === null) { + $paidAmount = $rows->filter(fn ($row) => data_get($row, 'payment_status') === 'PAID')->sum(fn ($row) => (float) data_get($row, 'payment_amount', 0)); + } + // Amounts are decimals in the invoice currency; allow for rounding only. + if ((float) $paidAmount + 0.01 < (float) $expected) { + Log::warning('[QPAY]: payment is less than the invoice amount; no order created', [ + 'checkout' => $checkout->public_id, + 'paid_amount' => $paidAmount, + 'expected' => $expected, + ]); + + return null; + } + } + + return (object) $paid; + } + + /** + * A value QPay accepts where it doesn't allow special characters (sender_invoice_no, + * invoice_receiver_code): letters and digits only, at most 45 characters. + */ + protected static function qpayCode(?string $value): string + { + return substr(preg_replace('/[^A-Za-z0-9]/', '', (string) $value), 0, 45); } /** @@ -1034,6 +1162,11 @@ protected function createOrderFromCheckout($checkout, $transactionDetails, $note 'transaction_id' => $transactionDetails['transaction_id'] ?? null, ]); + // captureOrder() works within the storefront of the request. QPay's callback + // carries no storefront key, so set the checkout's own storefront the way the + // storefront API does for app requests. + static::useCheckoutStorefront($checkout); + // Create CaptureOrderRequest with payment details $captureRequest = CaptureOrderRequest::create('', 'POST', [ 'token' => $checkout->token, @@ -1110,6 +1243,66 @@ protected function createOrderFromCheckout($checkout, $transactionDetails, $note * * @return void */ + /** + * The booking an order's items make, if any: a cart with a booked service (a bookable + * product with a chosen time) is a booking order. Products in it come with the earliest + * appointment, so the order follows the booking flow and carries that appointment's time. + * + * The time is kept in the order meta (`booking_at`), not `scheduled_at`: Fleet-Ops + * dispatches scheduled orders by themselves on the day, before the store confirms. + */ + protected static function bookingFor(iterable $cartItems): ?array + { + $items = collect($cartItems); + $productIds = $items->map(fn ($item) => data_get($item, 'product_id'))->filter()->unique()->values(); + $bookable = $productIds->isEmpty() ? collect() : Product::whereIn('public_id', $productIds)->where('is_bookable', true)->pluck('public_id'); + + $times = $items + ->filter(fn ($item) => data_get($item, 'scheduled_at') || $bookable->contains(data_get($item, 'product_id'))) + ->map(fn ($item) => data_get($item, 'scheduled_at')) + ->filter() + ->map(function ($at) { + try { + return Carbon::parse($at); + } catch (\Throwable $e) { + return null; + } + }) + ->filter() + ->sort(); + + $hasService = $items->contains(fn ($item) => data_get($item, 'scheduled_at') || $bookable->contains(data_get($item, 'product_id'))); + if (!$hasService) { + return null; + } + + $first = $times->first(); + + return [ + 'is_booking' => true, + 'booking_at' => $first ? $first->toIso8601String() : null, + 'booking_has_items' => $items->contains(fn ($item) => !data_get($item, 'scheduled_at') && !$bookable->contains(data_get($item, 'product_id'))), + ]; + } + + /** + * Mark an order as a booking (meta and the company's booking order config) when its items make one. + */ + protected static function applyBooking(iterable $cartItems, array $orderMeta, array $orderInput): array + { + $booking = static::bookingFor($cartItems); + if (!$booking) { + return [$orderMeta, $orderInput]; + } + + $config = Storefront::getBookingOrderConfig($orderInput['company_uuid'] ?? null); + if ($config) { + $orderInput['order_config_uuid'] = $config->uuid; + } + + return [array_merge($orderMeta, $booking), $orderInput]; + } + private function processCartItem($cartItem, $payload, $customer) { $product = Product::where('public_id', $cartItem->product_id)->first(); @@ -1171,7 +1364,9 @@ public function captureOrder(CaptureOrderRequest $request) $gateway = $checkout->is_cod ? Gateway::cash() : $checkout->gateway; $origin = $serviceQuote ? $serviceQuote->getMeta('origin', []) : null; $destination = $serviceQuote ? $serviceQuote->getMeta('destination') : null; - $cart = $checkout->cart; + // The cart as it was priced and charged (see Checkout::cartAtCheckout()); the live + // cart only for checkouts saved before the copy existed. + $cart = $checkout->cartAtCheckout() ?? $checkout->cart; // If the checkout already has an order created if ($checkout->order_uuid) { @@ -1411,6 +1606,10 @@ public function captureOrder(CaptureOrderRequest $request) 'notes' => $notes, ]; + // A booked service makes this a booking order, with its own flow. + [$bookingMeta, $orderInput] = static::applyBooking($cart->items ?? [], $orderInput['meta'], $orderInput); + $orderInput['meta'] = $bookingMeta; + // if it's integrated vendor order apply to meta if ($integratedVendorOrder) { $orderMeta['integrated_vendor'] = $serviceQuote->integratedVendor->public_id; @@ -1579,7 +1778,8 @@ public function captureMultipleOrders(CaptureOrderRequest $request) $origin = Arr::first($origins); $waypoints = array_slice($origins, 1); $destination = $serviceQuote->getMeta('destination'); - $cart = $checkout->cart; + // The cart as it was priced and charged (see Checkout::cartAtCheckout()). + $cart = $checkout->cartAtCheckout() ?? $checkout->cart; // $amount = $checkout->amount ?? ($checkout->is_pickup ? $cart->subtotal : $cart->subtotal + $serviceQuote->amount); $amount = static::calculateCheckoutAmount($cart, $serviceQuote, $checkout->options); $currency = $checkout->currency ?? $cart->getCurrency(); @@ -1608,6 +1808,14 @@ public function captureMultipleOrders(CaptureOrderRequest $request) $integratedVendorOrder = $vendorResult['order']; } + // Find each pickup's store before anything is created: an order that cannot be built + // fails here, without a payment record or half the orders left behind. + $originPlaces = collect($origins)->map(fn ($publicId) => Place::createFromMixed($publicId))->values(); + $originStores = $originPlaces->map(fn ($pickup) => $pickup instanceof Place ? Storefront::getStoreFromLocation($pickup->uuid) : null)->values(); + if ($originPlaces->isEmpty() || $originStores->contains(fn ($store) => !$store)) { + return response()->apiError('A store in this order could not be found, so it was not placed. Your payment has not been used.'); + } + // setup transaction meta $transactionMeta = [ 'storefront_network' => $about->name, @@ -1615,130 +1823,240 @@ public function captureMultipleOrders(CaptureOrderRequest $request) ...$transactionDetails, ]; - // create transactions for cart - $transaction = Transaction::create([ - 'company_uuid' => session('company'), - 'customer_uuid' => $customer->uuid, - 'customer_type' => Utils::getMutationType('fleet-ops:contact'), - 'gateway_transaction_id' => Utils::or($transactionDetails, ['id', 'transaction_id']) ?? Transaction::generateNumber(), - 'gateway' => $gateway->code, - 'gateway_uuid' => $gateway->uuid, - 'amount' => $amount, - 'currency' => $currency, - 'description' => 'Storefront network order', - 'type' => 'storefront', - 'status' => Transaction::STATUS_SUCCESS, - 'settlement_status' => Transaction::SETTLEMENT_STATUS_PAID, - 'settled_at' => now(), - 'settled_amount' => $amount, - 'settled_currency' => $currency, - 'meta' => $transactionMeta, - ]); + $transaction = null; + $multipleOrders = []; - // create transaction items - foreach ($cart->items as $cartItem) { - $store = Storefront::findAbout($cartItem->store_id); + try { + // create transactions for cart + $transaction = Transaction::create([ + 'company_uuid' => session('company'), + 'customer_uuid' => $customer->uuid, + 'customer_type' => Utils::getMutationType('fleet-ops:contact'), + 'gateway_transaction_id' => Utils::or($transactionDetails, ['id', 'transaction_id']) ?? Transaction::generateNumber(), + 'gateway' => $gateway->code, + 'gateway_uuid' => $gateway->uuid, + 'amount' => $amount, + 'currency' => $currency, + 'description' => 'Storefront network order', + 'type' => 'storefront', + 'status' => Transaction::STATUS_SUCCESS, + 'settlement_status' => Transaction::SETTLEMENT_STATUS_PAID, + 'settled_at' => now(), + 'settled_amount' => $amount, + 'settled_currency' => $currency, + 'meta' => $transactionMeta, + ]); - TransactionItem::create([ - 'transaction_uuid' => $transaction->uuid, - 'amount' => $cartItem->subtotal, - 'currency' => $checkout->currency, - 'details' => Storefront::getFullDescriptionFromCartItem($cartItem), - 'code' => 'product', - 'meta' => [ + // create transaction items + foreach ($cart->items as $cartItem) { + $store = Storefront::findAbout($cartItem->store_id); + + TransactionItem::create([ + 'transaction_uuid' => $transaction->uuid, + 'amount' => $cartItem->subtotal, + 'currency' => $checkout->currency, + 'details' => Storefront::getFullDescriptionFromCartItem($cartItem), + 'code' => 'product', + 'meta' => [ + 'storefront_network' => $about->name, + 'storefront_network_id' => $about->public_id, + 'storefront' => $store->name ?? null, + 'storefront_id' => $store->public_id ?? null, + ], + ]); + } + + // create transaction item for service quote + if (!$checkout->is_pickup) { + TransactionItem::create([ + 'transaction_uuid' => $transaction->uuid, + 'amount' => $serviceQuote->amount, + 'currency' => $serviceQuote->currency, + 'details' => 'Delivery fee', + 'code' => 'delivery_fee', + ]); + } + + // if tip create transaction item for tip + if ($checkout->hasOption('tip')) { + TransactionItem::create([ + 'transaction_uuid' => $transaction->uuid, + 'amount' => static::calculateTipAmount($checkout->getOption('tip'), $cart->subtotal), + 'currency' => $checkout->currency, + 'details' => 'Tip', + 'code' => 'tip', + ]); + } + + // if delivery tip create transaction item for tip + if ($checkout->hasOption('delivery_tip')) { + TransactionItem::create([ + 'transaction_uuid' => $transaction->uuid, + 'amount' => static::calculateTipAmount($checkout->getOption('delivery_tip'), $cart->subtotal), + 'currency' => $checkout->currency, + 'details' => 'Delivery Tip', + 'code' => 'delivery_tip', + ]); + } + + // if promotions were applied create a (credit) transaction item for the discount + $promotions = PromotionResult::fromArray(data_get($checkout->options, 'promotions')); + $discountAllocations = $promotions->allocationsByStore(); + static::createDiscountTransactionItem($transaction, $promotions, $checkout->currency); + + // payload pickups (resolved above) and the destination as places + $origins = $originPlaces; + $destination = Place::createFromMixed($destination); + + // The store tip goes to the network that runs the app, unless the network splits it + // across the stores, each by its share of the order. + $splitTips = $checkout->hasOption('tip') && $about->isOption('split_tips_across_stores'); + $tipShares = []; + if ($splitTips) { + $storeSubtotals = $originStores + ->unique('public_id') + ->mapWithKeys(fn ($store) => [$store->public_id => (int) $cart->getSubtotalForStore($store)]) + ->all(); + $tipShares = static::splitByShare((int) static::calculateTipAmount($checkout->getOption('tip'), $cart->subtotal), $storeSubtotals); + } + + $multipleOrders = []; + + foreach ($origins as $index => $pickup) { + $store = $originStores[$index]; + + // create payload + $payload = Payload::create([ + 'company_uuid' => $store->company_uuid, + 'pickup_uuid' => $pickup instanceof Place ? $pickup->uuid : null, + 'dropoff_uuid' => $destination instanceof Place ? $destination->uuid : null, + 'return_uuid' => $pickup instanceof Place ? $pickup->uuid : null, + 'payment_method' => $gateway->type, + 'type' => 'storefront', + ]); + + // get cart items from this store + $cartItems = $cart->getItemsForStore($store); + + // create entities + foreach ($cartItems as $cartItem) { + $this->processCartItem($cartItem, $payload, $customer); + } + + // get order subtotal and this store's share of the item discount + $subtotal = $cart->getSubtotalForStore($store); + $storeDiscount = min((int) ($discountAllocations[$store->public_id] ?? 0), (int) $subtotal); + + // prepare order meta + $orderMeta = [ + 'is_master_order' => false, + 'storefront' => $store->name, + 'storefront_id' => $store->public_id, 'storefront_network' => $about->name, 'storefront_network_id' => $about->public_id, - 'storefront' => $store->name ?? null, - 'storefront_id' => $store->public_id ?? null, - ], - ]); - } + 'checkout_id' => $checkout->public_id, + 'subtotal' => $subtotal, + 'delivery_fee' => 0, + 'tip' => $tipShares[$store->public_id] ?? 0, + 'tip_recipient' => $splitTips ? 'store' : 'network', + 'delivery_tip' => 0, + 'discount' => $storeDiscount, + 'total' => $subtotal - $storeDiscount, + 'currency' => $currency, + 'gateway' => $gateway->type, + 'require_pod' => $about->getOption('require_pod'), + 'pod_method' => $about->pod_method, + 'is_pickup' => $checkout->is_pickup, + ...$transactionDetails, + ]; - // create transaction item for service quote - if (!$checkout->is_pickup) { - TransactionItem::create([ - 'transaction_uuid' => $transaction->uuid, - 'amount' => $serviceQuote->amount, - 'currency' => $serviceQuote->currency, - 'details' => 'Delivery fee', - 'code' => 'delivery_fee', - ]); - } + // prepare order input + $orderInput = [ + 'company_uuid' => $store->company_uuid, + 'payload_uuid' => $payload->uuid, + 'customer_uuid' => $customer->uuid, + 'customer_type' => Utils::getMutationType('fleet-ops:contact'), + 'transaction_uuid' => $transaction->uuid, + 'order_config_uuid' => $store->getOrderConfigId(), + 'adhoc' => $about->isOption('auto_dispatch'), + 'type' => 'storefront', + 'status' => 'created', + 'notes' => $notes, + ]; - // if tip create transaction item for tip - if ($checkout->hasOption('tip')) { - TransactionItem::create([ - 'transaction_uuid' => $transaction->uuid, - 'amount' => static::calculateTipAmount($checkout->getOption('tip'), $cart->subtotal), - 'currency' => $checkout->currency, - 'details' => 'Tip', - 'code' => 'tip', - ]); - } + // if it's integrated vendor order apply to meta + if ($integratedVendorOrder) { + $orderMeta['integrated_vendor'] = $serviceQuote->integratedVendor->public_id; + $orderMeta['integrated_vendor_order'] = $integratedVendorOrder; + // order input + $orderInput['facilitator_uuid'] = $serviceQuote->integratedVendor->uuid; + $orderInput['facilitator_type'] = Utils::getModelClassName('integrated_vendors'); + } - // if delivery tip create transaction item for tip - if ($checkout->hasOption('delivery_tip')) { - TransactionItem::create([ - 'transaction_uuid' => $transaction->uuid, - 'amount' => static::calculateTipAmount($checkout->getOption('delivery_tip'), $cart->subtotal), - 'currency' => $checkout->currency, - 'details' => 'Delivery Tip', - 'code' => 'delivery_tip', - ]); - } + // A booked service makes this store's order a booking order, with its own flow. + [$orderMeta, $orderInput] = static::applyBooking($cartItems, $orderMeta, $orderInput); - // if promotions were applied create a (credit) transaction item for the discount - $promotions = PromotionResult::fromArray(data_get($checkout->options, 'promotions')); - $discountAllocations = $promotions->allocationsByStore(); - static::createDiscountTransactionItem($transaction, $promotions, $checkout->currency); + // set meta to order input last + $orderInput['meta'] = $orderMeta; - // convert payload destinations to Place - $origins = collect($origins)->map(function ($publicId) { - return Place::createFromMixed($publicId); - }); - $destination = Place::createFromMixed($destination); + // create order + $multipleOrders[] = $order = Order::create($orderInput); - $multipleOrders = []; + // set driving distance and time + $order->setPreliminaryDistanceAndTime(); - foreach ($origins as $pickup) { - $store = Storefront::getStoreFromLocation($pickup->uuid); + // purchase service quote + $order->purchaseQuote($serviceQuote->uuid, $transactionDetails); - // create payload + // if order is auto accepted update status + if ($store->isOption('auto_accept_orders')) { + $this->autoAcceptOrder($order); + if ($store->isOption('auto_dispatch')) { + $this->autoDispatchOrder($order); + } + } + + // notify order creation + Storefront::alertNewOrder($order); + } + + // convert origin to Place + $origin = Place::createFromMixed($origin); + + // create master payload $payload = Payload::create([ - 'company_uuid' => $store->company_uuid, - 'pickup_uuid' => $pickup instanceof Place ? $pickup->uuid : null, + 'company_uuid' => session('company'), + 'pickup_uuid' => $origin instanceof Place ? $origin->uuid : null, 'dropoff_uuid' => $destination instanceof Place ? $destination->uuid : null, - 'return_uuid' => $pickup instanceof Place ? $pickup->uuid : null, + 'return_uuid' => $origin instanceof Place ? $origin->uuid : null, 'payment_method' => $gateway->type, 'type' => 'storefront', - ]); - - // get cart items from this store - $cartItems = $cart->getItemsForStore($store); + ])->setWaypoints($waypoints); // create entities - foreach ($cartItems as $cartItem) { + foreach ($cart->items as $cartItem) { $this->processCartItem($cartItem, $payload, $customer); } - // get order subtotal and this store's share of the item discount - $subtotal = $cart->getSubtotalForStore($store); - $storeDiscount = min((int) ($discountAllocations[$store->public_id] ?? 0), (int) $subtotal); - - // prepare order meta - $orderMeta = [ - 'is_master_order' => false, - 'storefront' => $store->name, - 'storefront_id' => $store->public_id, + // prepare master order meta + $masterOrderMeta = [ + 'is_master_order' => true, + 'related_orders' => collect($multipleOrders)->pluck('public_id')->toArray(), + // the stores this order brings together, for lists that show it as one order + 'store_names' => $originStores->pluck('name')->filter()->unique()->values()->all(), + 'storefront' => $about->name, + 'storefront_id' => $about->public_id, 'storefront_network' => $about->name, 'storefront_network_id' => $about->public_id, 'checkout_id' => $checkout->public_id, - 'subtotal' => $subtotal, - 'delivery_fee' => 0, - 'tip' => 0, - 'delivery_tip' => 0, - 'discount' => $storeDiscount, - 'total' => $subtotal - $storeDiscount, + 'subtotal' => Utils::numbersOnly($cart->subtotal), + 'delivery_fee' => $checkout->is_pickup ? 0 : Utils::numbersOnly($serviceQuote->amount), + 'tip' => $checkout->getOption('tip'), + 'tip_recipient' => $splitTips ? 'stores' : 'network', + 'delivery_tip' => $checkout->getOption('delivery_tip'), + 'discount' => $promotions->discount(), + 'promotions' => $promotions->toPublicArray()['applied'], + 'total' => Utils::numbersOnly($amount), 'currency' => $currency, 'gateway' => $gateway->type, 'require_pod' => $about->getOption('require_pod'), @@ -1747,34 +2065,44 @@ public function captureMultipleOrders(CaptureOrderRequest $request) ...$transactionDetails, ]; - // prepare order input - $orderInput = [ - 'company_uuid' => $store->company_uuid, + // prepare master order input + $masterOrderInput = [ + 'company_uuid' => session('company'), 'payload_uuid' => $payload->uuid, 'customer_uuid' => $customer->uuid, 'customer_type' => Utils::getMutationType('fleet-ops:contact'), 'transaction_uuid' => $transaction->uuid, - 'order_config_uuid' => $store->getOrderConfigId(), + 'order_config_uuid' => $about->getOrderConfigId(), 'adhoc' => $about->isOption('auto_dispatch'), 'type' => 'storefront', 'status' => 'created', - 'notes' => $notes, ]; // if it's integrated vendor order apply to meta if ($integratedVendorOrder) { - $orderMeta['integrated_vendor'] = $serviceQuote->integratedVendor->public_id; - $orderMeta['integrated_vendor_order'] = $integratedVendorOrder; + $masterOrderMeta['integrated_vendor'] = $serviceQuote->integratedVendor->public_id; + $masterOrderMeta['integrated_vendor_order'] = $integratedVendorOrder; // order input - $orderInput['facilitator_uuid'] = $serviceQuote->integratedVendor->uuid; - $orderInput['facilitator_type'] = Utils::getModelClassName('integrated_vendors'); + $masterOrderInput['facilitator_uuid'] = $serviceQuote->integratedVendor->uuid; + $masterOrderInput['facilitator_type'] = Utils::getModelClassName('integrated_vendors'); } - // set meta to order input last - $orderInput['meta'] = $orderMeta; + // finally apply meta to master order + $masterOrderInput['meta'] = $masterOrderMeta; + + // create master order + $order = Order::create($masterOrderInput); - // create order - $multipleOrders[] = $order = Order::create($orderInput); + // record the promotions as used by this checkout's master order + PromotionRedemptions::redeem($checkout, $order); + + // update child orders with master order id in meta + foreach ($multipleOrders as $childOrder) { + $childOrder->updateMeta('master_order_id', $order->public_id); + } + + // notify driver if assigned + $order->notifyDriverAssigned(); // set driving distance and time $order->setPreliminaryDistanceAndTime(); @@ -1782,119 +2110,48 @@ public function captureMultipleOrders(CaptureOrderRequest $request) // purchase service quote $order->purchaseQuote($serviceQuote->uuid, $transactionDetails); - // if order is auto accepted update status - if ($store->isOption('auto_accept_orders')) { - $this->autoAcceptOrder($order); - if ($store->isOption('auto_dispatch')) { - $this->autoDispatchOrder($order); - } - } - - // notify order creation - Storefront::alertNewOrder($order); - } - - // convert origin to Place - $origin = Place::createFromMixed($origin); - - // create master payload - $payload = Payload::create([ - 'company_uuid' => session('company'), - 'pickup_uuid' => $origin instanceof Place ? $origin->uuid : null, - 'dropoff_uuid' => $destination instanceof Place ? $destination->uuid : null, - 'return_uuid' => $origin instanceof Place ? $origin->uuid : null, - 'payment_method' => $gateway->type, - 'type' => 'storefront', - ])->setWaypoints($waypoints); + // dispatch if flagged true + $order->firstDispatch(); - // create entities - foreach ($cart->items as $cartItem) { - $this->processCartItem($cartItem, $payload, $customer); - } + // update the cart with the checkout + $checkout->checkedout(); - // prepare master order meta - $masterOrderMeta = [ - 'is_master_order' => true, - 'related_orders' => collect($multipleOrders)->pluck('public_id')->toArray(), - 'storefront' => $about->name, - 'storefront_id' => $about->public_id, - 'storefront_network' => $about->name, - 'storefront_network_id' => $about->public_id, - 'checkout_id' => $checkout->public_id, - 'subtotal' => Utils::numbersOnly($cart->subtotal), - 'delivery_fee' => $checkout->is_pickup ? 0 : Utils::numbersOnly($serviceQuote->amount), - 'tip' => $checkout->getOption('tip'), - 'delivery_tip' => $checkout->getOption('delivery_tip'), - 'discount' => $promotions->discount(), - 'promotions' => $promotions->toPublicArray()['applied'], - 'total' => Utils::numbersOnly($amount), - 'currency' => $currency, - 'gateway' => $gateway->type, - 'require_pod' => $about->getOption('require_pod'), - 'pod_method' => $about->pod_method, - 'is_pickup' => $checkout->is_pickup, - ...$transactionDetails, - ]; + // update checkout token + $checkout->update([ + 'order_uuid' => $order->uuid, + // 'store_uuid' => $about->uuid, + 'captured' => true, + ]); - // prepare master order input - $masterOrderInput = [ - 'company_uuid' => session('company'), - 'payload_uuid' => $payload->uuid, - 'customer_uuid' => $customer->uuid, - 'customer_type' => Utils::getMutationType('fleet-ops:contact'), - 'transaction_uuid' => $transaction->uuid, - 'order_config_uuid' => $about->getOrderConfigId(), - 'adhoc' => $about->isOption('auto_dispatch'), - 'type' => 'storefront', - 'status' => 'created', - ]; + return new OrderResource($order); + } catch (\Throwable $e) { + // Undo this attempt's records so a retry ("Finish placing order") starts clean + // instead of leaving another payment record with no order. + static::discardFailedCapture($transaction, $multipleOrders); - // if it's integrated vendor order apply to meta - if ($integratedVendorOrder) { - $masterOrderMeta['integrated_vendor'] = $serviceQuote->integratedVendor->public_id; - $masterOrderMeta['integrated_vendor_order'] = $integratedVendorOrder; - // order input - $masterOrderInput['facilitator_uuid'] = $serviceQuote->integratedVendor->uuid; - $masterOrderInput['facilitator_type'] = Utils::getModelClassName('integrated_vendors'); + throw $e; } + } - // finally apply meta to master order - $masterOrderInput['meta'] = $masterOrderMeta; - - // create master order - $order = Order::create($masterOrderInput); - - // record the promotions as used by this checkout's master order - PromotionRedemptions::redeem($checkout, $order); - - // update child orders with master order id in meta - foreach ($multipleOrders as $childOrder) { - $childOrder->updateMeta('master_order_id', $order->public_id); + /** + * Remove what a failed capture attempt created: its transaction (with its line items) and + * any of the store orders already made. Nothing else refers to them yet. + */ + protected static function discardFailedCapture(?Transaction $transaction, array $orders = []): void + { + try { + foreach ($orders as $order) { + if ($order instanceof Order) { + $order->delete(); + } + } + if ($transaction) { + TransactionItem::where('transaction_uuid', $transaction->uuid)->delete(); + $transaction->delete(); + } + } catch (\Throwable $cleanup) { + Log::error('[Storefront] Unable to discard a failed order capture.', ['transaction' => $transaction?->public_id, 'error' => $cleanup->getMessage()]); } - - // notify driver if assigned - $order->notifyDriverAssigned(); - - // set driving distance and time - $order->setPreliminaryDistanceAndTime(); - - // purchase service quote - $order->purchaseQuote($serviceQuote->uuid, $transactionDetails); - - // dispatch if flagged true - $order->firstDispatch(); - - // update the cart with the checkout - $checkout->checkedout(); - - // update checkout token - $checkout->update([ - 'order_uuid' => $order->uuid, - // 'store_uuid' => $about->uuid, - 'captured' => true, - ]); - - return new OrderResource($order); } public function afterCheckout(Request $request) @@ -1943,64 +2200,57 @@ public function getCheckoutStatus(Request $request) 'order' => $checkout->order ? new OrderResource($checkout->order) : null, ]; - // Check if this is a QPay checkout - if ($checkout->gateway_uuid) { + // QPay: answered from the checkout, so the app can ask often and cheaply. The + // payment is recorded on the checkout as soon as QPay's callback (or a verify + // below) confirms it. QPay itself is asked only when the app says the customer + // is back from paying (verify=1), at most once every few seconds per checkout: + // QPay asks merchants to rely on the callback, not repeated payment checks. + if ($checkout->gateway_uuid && !$response['order']) { $gateway = Gateway::where('uuid', $checkout->gateway_uuid)->first(); if ($gateway && $gateway->code === 'qpay') { - // Get QPay invoice ID from checkout options - $qpayInvoiceId = $checkout->getOption('qpay_invoice_id'); - $payment = null; + $recorded = $checkout->getOption('qpay_payment'); - if ($qpayInvoiceId) { - // Create QPay instance with correct credentials + if (!$recorded && $request->boolean('verify') && $checkout->getOption('qpay_invoice_id') && Cache::add('storefront:qpay-verify:' . $checkout->uuid, 1, 5)) { $qpay = static::qpayForGateway($gateway); - if ($gateway->sandbox) { $qpay->useSandbox(); } - $qpay->setAuthToken(); - // Verify payment status with QPay - $paymentCheck = $qpay->paymentCheck($qpayInvoiceId); - $payment = data_get($paymentCheck, 'rows.0'); + $payment = static::paidQPayPayment($qpay->paymentCheck($checkout->getOption('qpay_invoice_id')), $checkout); + if ($payment) { + $recorded = static::recordQPayPayment($checkout, $payment); + static::publishCheckoutUpdate($checkout, true); + } } - if ($payment && $payment->payment_status === 'PAID') { + if ($recorded) { $response['status'] = 'paid'; - $response['payment'] = [ - 'payment_id' => $payment->payment_id, - 'payment_status' => $payment->payment_status, - 'payment_amount' => $payment->payment_amount, - 'payment_date' => $payment->payment_date ?? null, - 'payment_wallet' => $payment->payment_wallet ?? 'QPay', - ]; + $response['payment'] = $recorded; - // FALLBACK: If payment confirmed but order doesn't exist, create it - if (!$checkout->order_uuid) { + // Safety net: the callback creates the order right after recording the + // payment. If it still hasn't appeared a while later (e.g. the callback + // failed), create it here; createOrderFromCheckout is idempotent. + $recordedAt = data_get($recorded, 'recorded_at'); + if ($recordedAt && Carbon::parse($recordedAt)->lt(now()->subSeconds(15))) { Log::info('[CHECKOUT STATUS FALLBACK]: Payment confirmed but no order exists, attempting to create', [ 'checkout_id' => $checkout->public_id, - 'payment_id' => $payment->payment_id, + 'payment_id' => data_get($recorded, 'payment_id'), ]); - $transactionDetails = [ - 'transaction_id' => $payment->payment_id, - 'payment_status' => 'PAID', - 'payment_wallet' => $payment->payment_wallet ?? 'QPay', - ]; - try { - // Use the reusable gateway-agnostic method to create order - // createOrderFromCheckout has built-in idempotency checks - $order = $this->createOrderFromCheckout($checkout, $transactionDetails); + $order = $this->createOrderFromCheckout($checkout, [ + 'transaction_id' => data_get($recorded, 'payment_id'), + 'payment_status' => 'PAID', + 'payment_wallet' => data_get($recorded, 'payment_wallet') ?? 'QPay', + ]); if ($order) { $response['status'] = 'completed'; $response['order'] = new OrderResource($order); } } catch (\Exception $e) { - // If order creation fails (e.g., race condition), refresh and check again Log::warning('[CHECKOUT STATUS FALLBACK]: Order creation failed, checking if order was created by another request', [ 'checkout_id' => $checkout->public_id, 'error' => $e->getMessage(), @@ -2008,15 +2258,10 @@ public function getCheckoutStatus(Request $request) $checkout->refresh(); if ($checkout->order_uuid) { - // Order was created by another request $response['status'] = 'completed'; $response['order'] = new OrderResource($checkout->order); } } - } else { - // Order already exists - $response['status'] = 'completed'; - $response['order'] = new OrderResource($checkout->order); } } } @@ -2143,6 +2388,75 @@ protected static function promotionCodesFor(Cart $cart, Request $request): array return array_values(array_unique(array_filter(array_merge((array) $codes, $cart->getPromotionCodes()), 'is_string'))); } + /** + * The JSON response for an initialized checkout. + * + * When realtime socket authentication is enabled it carries `socket_token`: a + * `checkout` token whose scope is exactly this checkout's channel, so a client + * (a guest included) can listen for its own payment confirmation. The field is + * absent while socket authentication is disabled. + */ + protected static function checkoutResponse(Checkout $checkout, array $data): JsonResponse + { + $socketToken = StorefrontSocket::checkoutToken($checkout); + if ($socketToken) { + $data['socket_token'] = $socketToken; + } + + return response()->json($data); + } + + /** + * Publishes a checkout's progress on its realtime channel. + * + * The payload is what a storefront client acts on — the checkout, its status, the + * order once one exists (serialized exactly as GET checkouts/status returns it) and + * any error — never the raw gateway payment record. A publish failure is logged and + * swallowed: by now the payment is recorded, and clients still recover the outcome + * through GET checkouts/status. + * + * @return array|null the published payload, or null when publishing failed + */ + protected static function publishCheckoutUpdate(Checkout $checkout, bool $paid, ?array $error = null): ?array + { + try { + // A failed payment carries no order, so a client never completes on an error event. + $order = !$error && $checkout->order_uuid ? Order::where('uuid', $checkout->order_uuid)->first() : null; + $status = 'pending'; + if ($error) { + $status = 'failed'; + } elseif ($order) { + $status = 'completed'; + } elseif ($paid) { + $status = 'paid'; + } + + $data = [ + 'checkout' => $checkout->public_id, + 'status' => $status, + 'order' => $order ? static::checkoutChannelOrder($order) : null, + 'payment' => !$error ? $checkout->getOption('qpay_payment') : null, + 'error' => $error, + ]; + + SocketClusterService::publish(StorefrontSocket::checkoutChannel($checkout), $data); + + return $data; + } catch (\Throwable $e) { + Log::warning('[CHECKOUT SOCKET PUBLISH FAILED]: ' . $e->getMessage(), ['checkout' => $checkout->public_id]); + + return null; + } + } + + /** + * Serializes a checkout's order for its realtime channel, as GET checkouts/status does. + */ + protected static function checkoutChannelOrder(Order $order): array + { + return json_decode(json_encode(new OrderResource($order)), true); + } + /** * Reserve a new checkout's promotions, discarding the checkout if one ran out meanwhile. */ @@ -2159,6 +2473,39 @@ protected static function reservePromotions(Checkout $checkout, $checkoutOptions return null; } + /** + * Split an amount across stores in proportion to their subtotals. Shares are whole cents + * that add up to the amount; the cents left over by rounding go to the largest shares. + * + * @param array $subtotals store id => subtotal + * + * @return array store id => share + */ + protected static function splitByShare(int $amount, array $subtotals): array + { + $total = array_sum($subtotals); + if ($amount <= 0 || $total <= 0) { + return array_map(fn () => 0, $subtotals); + } + + $shares = []; + foreach ($subtotals as $storeId => $subtotal) { + $shares[$storeId] = intdiv($amount * $subtotal, $total); + } + + $left = $amount - array_sum($shares); + arsort($subtotals); + foreach (array_keys($subtotals) as $storeId) { + if ($left <= 0) { + break; + } + $shares[$storeId]++; + $left--; + } + + return $shares; + } + private static function calculateTipAmount($tip, $subtotal) { $tipAmount = 0; diff --git a/server/src/Http/Controllers/v1/CustomerController.php b/server/src/Http/Controllers/v1/CustomerController.php index 1aa827e4..01ea14f2 100644 --- a/server/src/Http/Controllers/v1/CustomerController.php +++ b/server/src/Http/Controllers/v1/CustomerController.php @@ -22,6 +22,8 @@ use Fleetbase\Storefront\Http\Resources\Customer; use Fleetbase\Storefront\Push\StorefrontPushChannel; use Fleetbase\Storefront\Support\Storefront; +use Fleetbase\Storefront\Support\StorefrontSocket; +use Fleetbase\Support\SocketCluster\SocketToken; use Fleetbase\Support\Utils; use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Http\Request; @@ -127,6 +129,36 @@ public function unregisterDevice(Request $request) ]); } + /** + * Mints a realtime socket token for the signed-in customer. + * + * POST storefront/v1/customers/socket-token — authenticated like every other + * customer endpoint: the storefront key plus a Customer-Token header. The token + * is a `customer` principal scoped to the store or network the key belongs to; + * the socket server only lets it subscribe to channels the customer owns. + * Returns 404 while socket authentication is not configured on this instance. + */ + public function socketToken(Request $request) + { + if (!SocketToken::enabled()) { + return response()->apiError('Not found.', 404); + } + + $customer = Storefront::getCustomerFromToken(); + if (!$customer) { + return response()->apiError('Not authorized to create a socket token for customer.', 401); + } + + // A customer's token is only honoured by the storefront whose company the + // customer belongs to, so a token minted against another company's key is refused. + $storefront = Storefront::about(); + if (!$storefront || $storefront->company_uuid !== $customer->company_uuid) { + return response()->apiError('Not authorized to create a socket token for customer.', 401); + } + + return response()->json(SocketToken::issue(StorefrontSocket::customerPrincipal($customer, $storefront))); + } + /** * Newer core-api versions add push metadata columns to user_devices. * @@ -153,12 +185,10 @@ public function orders(Request $request) $results = Order::queryWithRequest($request, function (&$query) use ($customer) { $query->where('customer_uuid', $customer->uuid)->whereNull('deleted_at')->withoutGlobalScopes(); - // dont query any master orders if its a network + // A multi-store checkout is one order to the customer: list its master order (which + // shows each store's part) and leave out the stores' own orders. if (session('storefront_network')) { - $query->where(function ($q) { - $q->where('meta->is_master_order', false); - $q->orWhere('meta', 'not like', '%related_orders%'); - }); + $query->whereNull('meta->master_order_id'); } }, true); @@ -1030,12 +1060,13 @@ public function getStripeEphemeralKey(Request $request) \Stripe\Stripe::setApiKey($gateway->config->secret_key); - // Ensure customer has a stripe_id - if ($customer->missingMeta('stripe_id')) { - Storefront::createStripeCustomerForContact($customer); - } - try { + // Ensure customer has a stripe_id. Inside the try: Stripe refusing the gateway's key + // must come back as an error, not an uncaught exception rendered as 401. + if ($customer->missingMeta('stripe_id')) { + Storefront::createStripeCustomerForContact($customer); + } + // Create Ephemeral Key $ephemeralKey = \Stripe\EphemeralKey::create( ['customer' => $customer->getMeta('stripe_id')], @@ -1065,12 +1096,13 @@ public function getStripeSetupIntent(Request $request) \Stripe\Stripe::setApiKey($gateway->config->secret_key); - // Ensure customer has a stripe_id - if ($customer->missingMeta('stripe_id')) { - Storefront::createStripeCustomerForContact($customer); - } - try { + // Ensure customer has a stripe_id. Inside the try: Stripe refusing the gateway's key + // must come back as an error, not an uncaught exception rendered as 401. + if ($customer->missingMeta('stripe_id')) { + Storefront::createStripeCustomerForContact($customer); + } + // Create SetupIntent $setupIntent = \Stripe\SetupIntent::create([ 'customer' => $customer->getMeta('stripe_id'), diff --git a/server/src/Http/Controllers/v1/FoodTruckController.php b/server/src/Http/Controllers/v1/FoodTruckController.php index be35a803..4f07070b 100644 --- a/server/src/Http/Controllers/v1/FoodTruckController.php +++ b/server/src/Http/Controllers/v1/FoodTruckController.php @@ -5,6 +5,7 @@ use Fleetbase\Http\Controllers\Controller; use Fleetbase\Storefront\Http\Resources\FoodTruck as FoodTruckResource; use Fleetbase\Storefront\Models\FoodTruck; +use Fleetbase\Storefront\Models\Network; use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Http\Request; @@ -17,25 +18,47 @@ class FoodTruckController extends Controller */ public function query(Request $request) { - $limit = $request->input('limit', false); - $offset = $request->input('offset', false); - $results = []; + $limit = $request->input('limit', false); + $offset = $request->input('offset', false); + $storeIds = static::storeUuidsForStorefront(); + if (empty($storeIds)) { + return FoodTruckResource::collection([]); + } + + $results = FoodTruck::queryWithRequestCached($request, function (&$query) use ($limit, $offset, $storeIds) { + $query->whereIn('store_uuid', $storeIds)->with(['store', 'vehicle', 'zone', 'serviceArea', 'catalogs']); + + if ($limit) { + $query->limit($limit); + } + + if ($offset) { + $query->offset($offset); + } + }); + return FoodTruckResource::collection($results); + } + + /** + * The stores whose food trucks this storefront shows: a store's own, or in a network + * every member store's, so a network can run trucks alongside its stores. + * + * @return array + */ + protected static function storeUuidsForStorefront(): array + { if (session('storefront_store')) { - $results = FoodTruck::queryWithRequestCached($request, function (&$query) use ($limit, $offset) { - $query->where('store_uuid', session('storefront_store')); + return [session('storefront_store')]; + } - if ($limit) { - $query->limit($limit); - } + if (session('storefront_network')) { + $network = Network::where('uuid', session('storefront_network'))->first(); - if ($offset) { - $query->offset($offset); - } - }); + return $network ? $network->stores()->pluck('stores.uuid')->all() : []; } - return FoodTruckResource::collection($results); + return []; } /** @@ -52,6 +75,13 @@ public function find($id) return response()->error('Food Truck resource not found.'); } + // Only a truck of this storefront (its own, or a network member store's). + if (!in_array($foodTruck->store_uuid, static::storeUuidsForStorefront(), true)) { + return response()->error('Food Truck resource not found.', 404); + } + + $foodTruck->loadMissing(['store', 'vehicle', 'zone', 'serviceArea', 'catalogs']); + // response the product resource return new FoodTruckResource($foodTruck); } diff --git a/server/src/Http/Controllers/v1/NetworkController.php b/server/src/Http/Controllers/v1/NetworkController.php index d6514f73..8db88e6e 100644 --- a/server/src/Http/Controllers/v1/NetworkController.php +++ b/server/src/Http/Controllers/v1/NetworkController.php @@ -14,6 +14,28 @@ class NetworkController extends Controller { + /** + * The point to measure store distances from, or null when the request has no usable location. + * + * The coordinate parser falls back to (0, 0) for missing or invalid input. Treating that as a + * location made every store's distance be measured from the Gulf of Guinea, filtered out every + * store when maximum_distance was set, and loaded the whole network for in-memory sorting. + */ + protected static function resolveReferencePoint($location): ?Point + { + if (blank($location)) { + return null; + } + + $point = Utils::getPointFromCoordinates($location); + + if ((float) $point->getLat() === 0.0 && (float) $point->getLng() === 0.0) { + return null; + } + + return $point; + } + /** * Returns all stores within the network. * @@ -34,7 +56,7 @@ public function stores(Request $request) $location = $request->input('location'); $maxDistance = $request->input('maximum_distance', null); $exclude = $request->input('exclude', []); - $coordinates = Utils::getPointFromCoordinates($location); + $coordinates = static::resolveReferencePoint($location); $requiresDistancePostProcessing = $coordinates instanceof Point && ($sort === 'nearest' || is_numeric($maxDistance)); if (is_string($tagged)) { @@ -52,6 +74,7 @@ public function stores(Request $request) /** @var \Illuminate\Database\Query\Builder $query */ $query = Store::select('*') ->with(['logo', 'backdrop', 'media', 'locations.place']) + ->withAvg('reviews', 'rating') ->whereHas('locations') ->whereHas('networks', function ($q) use ($request) { $q->where('network_uuid', session('storefront_network')); @@ -114,10 +137,10 @@ public function stores(Request $request) switch ($sort) { case 'highest_rated': - $query->withAvg('reviews', 'rating')->orderByDesc('reviews_avg_rating'); + $query->orderByDesc('reviews_avg_rating'); break; case 'lowest_rated': - $query->withAvg('reviews', 'rating')->orderBy('reviews_avg_rating'); + $query->orderBy('reviews_avg_rating'); break; case 'newest': $query->orderByDesc('created_at'); diff --git a/server/src/Http/Controllers/v1/OrderChatController.php b/server/src/Http/Controllers/v1/OrderChatController.php new file mode 100644 index 00000000..e306c93b --- /dev/null +++ b/server/src/Http/Controllers/v1/OrderChatController.php @@ -0,0 +1,245 @@ +resolveContext($id); + if ($context instanceof JsonResponse) { + return $context; + } + [$order, $customer, $channel] = $context; + + $customerUserUuid = $customer->user_uuid; + $participant = OrderChat::participantFor($channel, $customerUserUuid); + $messages = $this->messageQuery($channel)->limit(static::PAGE_SIZE)->get()->reverse()->values(); + $unread = $participant ? $channel->getUnreadMessagesForParticipant($participant)->count() : 0; + + $channel->load(['participants.user']); + + return new OrderChatResource($channel, $order, $customerUserUuid, $messages, $unread); + } + + /** + * Older messages, newest first in the query and returned oldest first. + * + * Query params: `before` (a message id) and `limit` (up to 100). + */ + public function messages(Request $request, string $id) + { + $context = $this->resolveContext($id); + if ($context instanceof JsonResponse) { + return $context; + } + [, $customer, $channel] = $context; + + $limit = min(max((int) $request->input('limit', static::PAGE_SIZE), 1), static::MAX_PAGE_SIZE); + $query = $this->messageQuery($channel); + + if ($request->filled('before')) { + $before = ChatMessage::where('chat_channel_uuid', $channel->uuid)->where('public_id', $request->input('before'))->first(); + if (!$before) { + return response()->apiError('Message not found.', 404); + } + $query->where(fn ($older) => $older->where('created_at', '<', $before->created_at) + ->orWhere(fn ($sameTime) => $sameTime->where('created_at', $before->created_at)->where('id', '<', $before->id))); + } + + $messages = $query->limit($limit)->get()->reverse()->values(); + + return response()->json(['messages' => OrderChatMessage::list($messages, $customer->user_uuid)]); + } + + /** + * Send a message as the customer. Closed once the order is completed or canceled. + */ + public function send(SendOrderChatMessageRequest $request, string $id) + { + $context = $this->resolveContext($id); + if ($context instanceof JsonResponse) { + return $context; + } + [$order, $customer, $channel] = $context; + + if (OrderChat::isClosed($order)) { + return response()->json(['error' => 'This chat closed when the order finished.', 'reason' => 'chat_closed'], 423); + } + + $sender = OrderChat::participantFor($channel, $customer->user_uuid); + if (!$sender) { + return response()->apiError('You are not part of this chat.', 403); + } + + $message = ChatMessage::create([ + 'company_uuid' => $channel->company_uuid, + 'chat_channel_uuid' => $channel->uuid, + 'sender_uuid' => $sender->uuid, + 'content' => (string) $request->input('content', ''), + ]); + + foreach ((array) $request->input('files', []) as $upload) { + $this->attachPhoto($message, $sender, $customer, $upload); + } + + $message->load(['sender.user', 'attachments.file', 'receipts']); + $message->notifyParticipants(); + + return response()->json(['message' => (new OrderChatMessage($message, $customer->user_uuid))->resolve()]); + } + + /** + * Mark every message from the driver as read by the customer. + */ + public function read(string $id) + { + $context = $this->resolveContext($id); + if ($context instanceof JsonResponse) { + return $context; + } + [, $customer, $channel] = $context; + + $participant = OrderChat::participantFor($channel, $customer->user_uuid); + if (!$participant) { + return response()->apiError('You are not part of this chat.', 403); + } + + $unread = $channel->getUnreadMessagesForParticipant($participant); + foreach ($unread as $message) { + ChatReceipt::create([ + 'company_uuid' => $channel->company_uuid, + 'chat_message_uuid' => $message->uuid, + 'participant_uuid' => $participant->uuid, + ]); + } + + return response()->json(['read' => $unread->count()]); + } + + /** + * Resolve the signed-in customer, their order in this storefront and its chat. + * + * @return array{0: Order, 1: Contact, 2: ChatChannel}|JsonResponse + */ + protected function resolveContext(string $id): array|JsonResponse + { + $customer = Storefront::getCustomerFromToken(); + if (!$customer) { + return response()->apiError('Customer is not authenticated.', 401); + } + + $order = Order::where('public_id', $id)->with(['customer', 'driverAssigned'])->first(); + if (!$order || !$this->belongsToStorefront($order)) { + return response()->apiError('Order not found.', 404); + } + + if ($order->customer_uuid !== $customer->uuid) { + return response()->apiError('Not authorized to view this order.', 403); + } + + // Once the order is finished the chat stays readable, but is not started any more. + $channel = OrderChat::isClosed($order) ? OrderChat::find($order) : OrderChat::open($order); + if (!$channel) { + return response()->json([ + 'error' => 'You can message your driver once one is assigned to this order.', + 'reason' => 'driver_not_assigned', + ], 409); + } + + return [$order, $customer, $channel]; + } + + /** + * Whether the order was placed through the storefront the request is made with. + */ + protected function belongsToStorefront(Order $order): bool + { + $about = Storefront::about(); + if (!$about) { + return false; + } + + $storefrontId = $about->is_network ? $order->getMeta('storefront_network_id') : $order->getMeta('storefront_id'); + + return $storefrontId === $about->public_id; + } + + protected function messageQuery(ChatChannel $channel): Builder + { + return ChatMessage::where('chat_channel_uuid', $channel->uuid) + ->with(['sender.user', 'attachments.file', 'receipts']) + ->orderByDesc('created_at') + ->orderByDesc('id'); + } + + /** + * Store a base64 photo and attach it to the message. Written without a public ACL: the + * media bucket is private and file URLs are served through the File model. + * + * @param array{data?: string, type?: string} $upload + */ + protected function attachPhoto(ChatMessage $message, ChatParticipant $sender, Contact $customer, array $upload): void + { + $disk = config('filesystems.default'); + $bucket = config('filesystems.disks.' . $disk . '.bucket', config('filesystems.disks.s3.bucket')); + $data = base64_decode((string) ($upload['data'] ?? '')); + $mimeType = (string) ($upload['type'] ?? 'image/jpeg'); + $extension = File::getExtensionFromMimeType($mimeType); + $path = 'hyperstore/' . data_get(Storefront::about(), 'public_id') . '/order-chat/' . $message->chat_channel_uuid . '/' . File::randomFileName($extension); + + Storage::disk($disk)->put($path, $data); + + $file = File::create([ + 'company_uuid' => $message->company_uuid, + 'uploader_uuid' => $customer->user_uuid, + 'subject_uuid' => $message->uuid, + 'subject_type' => ChatMessage::class, + 'disk' => $disk, + 'original_filename' => basename($path), + 'content_type' => $mimeType, + 'path' => $path, + 'bucket' => $bucket, + 'type' => 'storefront_chat_upload', + 'file_size' => strlen($data), + ]); + + ChatAttachment::create([ + 'company_uuid' => $message->company_uuid, + 'chat_channel_uuid' => $message->chat_channel_uuid, + 'chat_message_uuid' => $message->uuid, + 'sender_uuid' => $sender->uuid, + 'file_uuid' => $file->uuid, + ]); + } +} diff --git a/server/src/Http/Controllers/v1/OrderController.php b/server/src/Http/Controllers/v1/OrderController.php index 09119489..9dcfd7d9 100644 --- a/server/src/Http/Controllers/v1/OrderController.php +++ b/server/src/Http/Controllers/v1/OrderController.php @@ -5,6 +5,8 @@ use Fleetbase\FleetOps\Models\Order; use Fleetbase\Http\Controllers\Controller; use Fleetbase\Storefront\Models\Checkout; +use Fleetbase\Storefront\Models\Store; +use Fleetbase\Storefront\Support\OrderActivityFlow; use Fleetbase\Storefront\Support\QPay; use Fleetbase\Storefront\Support\Storefront; use Illuminate\Http\JsonResponse; @@ -57,6 +59,108 @@ protected function updateOrderStatus(Order $order, string $status) return $order->updateStatus($status); } + /** + * The stores a multi-store (network) order was placed with: each store's own order, its + * progress, the items it is preparing and its subtotal. The customer's order is the one + * delivery that brings them together; this is its breakdown by store. Empty for an order + * from a single store. + */ + public function getStores(string $id) + { + $customer = Storefront::getCustomerFromToken(); + if (!$customer) { + return response()->apiError('Customer is not authenticated.'); + } + + $order = Order::where('public_id', $id)->whereNull('deleted_at')->first(); + if (!$order) { + return response()->apiError('No order found.', 404); + } + + if ($order->customer_uuid !== $customer->uuid) { + return response()->apiError('Not authorized to view this order.', 403); + } + + $related = array_values(array_filter((array) $order->getMeta('related_orders', []))); + if (!$order->getMeta('is_master_order') || empty($related)) { + return response()->json(['stores' => []]); + } + + $children = Order::whereIn('public_id', $related) + ->where('customer_uuid', $customer->uuid) + ->whereNull('deleted_at') + ->with(['payload.pickup', 'payload.entities']) + ->get() + ->sortBy(fn ($child) => array_search($child->public_id, $related)) + ->values(); + + $storeIds = $children->map(fn ($child) => $child->getMeta('storefront_id'))->filter()->unique()->values(); + $stores = Store::whereIn('public_id', $storeIds)->get()->keyBy('public_id'); + + $sections = $children->map(function (Order $child) use ($stores) { + $store = $stores->get($child->getMeta('storefront_id')); + $pickup = data_get($child, 'payload.pickup'); + $flow = OrderActivityFlow::forOrder($child); + $step = collect($flow['steps'] ?? [])->firstWhere('state', 'current') ?? collect($flow['steps'] ?? [])->last(); + + return [ + 'order' => $child->public_id, + 'status' => $child->status, + 'label' => data_get($step, 'label'), + 'store' => [ + 'id' => $store?->public_id ?? $child->getMeta('storefront_id'), + 'name' => $store?->name ?? $child->getMeta('storefront'), + 'logo_url' => $store?->logo_url, + 'phone' => $store?->phone, + 'address' => $pickup ? ($pickup->address ?? null) : null, + ], + 'items' => collect(data_get($child, 'payload.entities', []))->map(fn ($entity) => [ + 'id' => $entity->public_id, + 'name' => $entity->name, + 'quantity' => (int) (data_get($entity, 'meta.quantity') ?: 1), + 'subtotal' => (int) data_get($entity, 'meta.subtotal', 0), + 'variants' => data_get($entity, 'meta.variants', []), + 'addons' => data_get($entity, 'meta.addons', []), + 'image_url' => data_get($entity, 'meta.image_url') ?? $entity->photo_url ?? null, + ])->values(), + 'subtotal' => (int) $child->getMeta('subtotal', 0), + 'discount' => (int) $child->getMeta('discount', 0), + 'tip' => (int) $child->getMeta('tip', 0), + 'currency' => $child->getMeta('currency'), + ]; + })->values(); + + return response()->json(['stores' => $sections]); + } + + /** + * The customer's order as a line of steps from its own order config: where it has been, + * where it is, and where it is expected to go (see OrderActivityFlow). + * + * GET storefront/v1/orders/{id}/activity-flow + */ + public function getActivityFlow(string $id) + { + $customer = Storefront::getCustomerFromToken(); + if (!$customer) { + return response()->apiError('Customer is not authenticated.'); + } + + $order = Order::where('public_id', $id) + ->whereNull('deleted_at') + ->first(); + + if (!$order) { + return response()->apiError('No order found.', 404); + } + + if ($order->customer_uuid !== $customer->uuid) { + return response()->apiError('Not authorized to view this order.', 403); + } + + return response()->json(OrderActivityFlow::forOrder($order)); + } + /** * Get receipt for an order based on the payment method type. * diff --git a/server/src/Http/Controllers/v1/PromotionController.php b/server/src/Http/Controllers/v1/PromotionController.php index d9d106f0..0b4ef1fe 100644 --- a/server/src/Http/Controllers/v1/PromotionController.php +++ b/server/src/Http/Controllers/v1/PromotionController.php @@ -18,25 +18,41 @@ class PromotionController extends Controller /** * Live, public promotions of the storefront, and in a network of its member stores. * - * Query params: `store` (a store public id, to only list that store's promotions in a network). + * Query params: + * - `store`: a store public id, to only list that store's promotions in a network. + * - `include=scheduled`: also list active promotions that are outside their weekly hours or + * have not started yet, so the app can show "starts again at 2 pm". Each promotion's + * `availability` says which it is. */ public function query(Request $request) { + $listed = $request->input('include') === 'scheduled' + ? [Promotion::AVAILABILITY_LIVE, Promotion::AVAILABILITY_SCHEDULED] + : [Promotion::AVAILABILITY_LIVE]; + $promotions = $this->publicPromotions($request->input('store')) + ->where('status', Promotion::STATUS_ACTIVE) ->orderByDesc('priority') ->orderBy('ends_at') ->get() - ->filter(fn (Promotion $promotion) => $promotion->isLiveAt()) + ->filter(fn (Promotion $promotion) => in_array($promotion->availabilityAt(), $listed, true)) ->values(); return PromotionResource::collection($promotions); } + /** + * One public promotion. Scheduled and ended promotions are returned too, so a link from a + * notification still opens and can say when the offer runs or that it is over. + */ public function find(string $id) { - $promotion = $this->publicPromotions()->where('public_id', $id)->first(); + $promotion = $this->publicPromotions() + ->whereIn('status', [Promotion::STATUS_ACTIVE, Promotion::STATUS_ENDED]) + ->where('public_id', $id) + ->first(); - if (!$promotion || !$promotion->isLiveAt()) { + if (!$promotion || $promotion->availabilityAt() === Promotion::AVAILABILITY_INACTIVE) { return response()->apiError('Promotion not found.', 404); } @@ -58,8 +74,7 @@ protected function publicPromotions(?string $storeId = null): Builder return Promotion::query() ->whereIn('owner_uuid', array_filter($owners)) - ->where('status', Promotion::STATUS_ACTIVE) ->where('is_public', true) - ->with('image'); + ->with(['image', 'owner', 'codes']); } } diff --git a/server/src/Http/Controllers/v1/ReviewController.php b/server/src/Http/Controllers/v1/ReviewController.php index dd51ba78..dbce143a 100644 --- a/server/src/Http/Controllers/v1/ReviewController.php +++ b/server/src/Http/Controllers/v1/ReviewController.php @@ -8,9 +8,11 @@ use Fleetbase\Models\File; use Fleetbase\Storefront\Http\Requests\CreateReviewRequest; use Fleetbase\Storefront\Http\Resources\Review as StorefrontReview; +use Fleetbase\Storefront\Models\Network; use Fleetbase\Storefront\Models\Product; use Fleetbase\Storefront\Models\Review; use Fleetbase\Storefront\Models\Store; +use Fleetbase\Storefront\Support\ReviewEligibility; use Fleetbase\Storefront\Support\Storefront; use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Http\Request; @@ -70,6 +72,23 @@ protected function subjectBelongsToContext($subject): bool return false; } + /** + * Whether reviews are switched off for this storefront. A network's setting covers all of + * its stores and reviews are only on when it is switched on; a single store's app keeps + * reviews unless the store switches them off. + */ + protected static function reviewsDisabled(): bool + { + $about = Storefront::about(); + if (!$about) { + return false; + } + + $enabled = data_get($about->options, 'reviews_enabled'); + + return $about instanceof Network ? $enabled !== true : $enabled === false; + } + /** * Query for Storefront Review resources. * @@ -77,6 +96,10 @@ protected function subjectBelongsToContext($subject): bool */ public function query(Request $request) { + if (static::reviewsDisabled()) { + return StorefrontReview::collection([]); + } + $results = []; $limit = $request->input('limit', false); $offset = $request->input('offset', false); @@ -128,28 +151,30 @@ public function query(Request $request) public function applySort($request, $sort) { if ($sort) { + // Core's query builder reads the direction from a "-" prefix on each column (it has no + // sort_direction parameter). Equal ratings list the newest review first. switch ($sort) { case 'highest': case 'highest rated': - $request->merge(['sort' => 'rating', 'sort_direction' => 'desc']); + $request->merge(['sort' => ['-rating', '-created_at']]); break; case 'lowest': case 'lowest rated': - $request->merge(['sort' => 'rating', 'sort_direction' => 'asc']); + $request->merge(['sort' => ['rating', '-created_at']]); break; case 'newest': case 'newest first': - $request->merge(['sort' => 'created_at', 'sort_direction' => 'desc']); + $request->merge(['sort' => ['-created_at']]); break; case 'oldest': case 'oldest first': - $request->merge(['sort' => 'created_at', 'sort_direction' => 'asc']); + $request->merge(['sort' => ['created_at']]); break; @@ -170,6 +195,10 @@ public function count(Request $request) $counts = []; $range = range(1, 5); + if (static::reviewsDisabled()) { + return response()->json(array_fill_keys($range, 0)); + } + if (session('storefront_store')) { foreach ($range as $rating) { $counts[$rating] = Review::where(['subject_uuid' => session('storefront_store'), 'rating' => $rating])->count(); @@ -216,6 +245,33 @@ public function find($id) return new StorefrontReview($review); } + /** + * Whether the signed-in customer can review a store or product, and why not. + * + * The app calls this before showing "Write a review", so a customer who has not completed + * an order, or has already reviewed every completed one, sees an explanation instead. + */ + public function eligibility(Request $request) + { + $subjectId = $request->input('subject'); + if (!is_string($subjectId) || $subjectId === '') { + return response()->error('A subject is required.'); + } + + $subject = Utils::resolveSubject($subjectId); + if (!$subject || !$this->subjectBelongsToContext($subject)) { + return response()->error('Invalid subject for review'); + } + + if (static::reviewsDisabled()) { + return response()->json(['can_review' => false, 'reason' => 'reviews_disabled', 'message' => 'Reviews are turned off.', 'order' => null, 'review' => null]); + } + + $eligibility = ReviewEligibility::check(Storefront::getCustomerFromToken(), $subject, $request->input('order')); + + return response()->json($eligibility->toArray()); + } + /** * Create a review. * @@ -232,15 +288,25 @@ public function create(CreateReviewRequest $request) return response()->error('Not authorized to create reviews'); } + if (static::reviewsDisabled()) { + return response()->json(['error' => 'Reviews are turned off.', 'reason' => 'reviews_disabled'], 403); + } + $subject = Utils::resolveSubject($request->input('subject')); if (!$subject || !$this->subjectBelongsToContext($subject)) { return response()->error('Invalid subject for review'); } + $eligibility = ReviewEligibility::check($customer, $subject, $request->input('order')); + if (!$eligibility->allowed) { + return response()->json(['error' => $eligibility->message(), 'reason' => $eligibility->reason], 403); + } + $review = Review::create([ 'created_by_uuid' => $customer->user_uuid, 'customer_uuid' => $customer->uuid, + 'order_uuid' => $eligibility->order->uuid, 'subject_uuid' => $subject->uuid, 'subject_type' => Utils::getMutationType($subject), 'rating' => $request->input('rating'), @@ -258,8 +324,8 @@ public function create(CreateReviewRequest $request) $extension = File::getExtensionFromMimeType($mimeType); $bucketPath = 'hyperstore/' . $about->public_id . '/review-photos/' . $review->uuid . '/' . File::randomFileName($extension); - // upload file to path - $upload = Storage::disk($disk)->put($bucketPath, base64_decode($data), 'public'); + // upload file to path; no 'public' ACL, the S3 media bucket is private and rejects ACLs + $upload = Storage::disk($disk)->put($bucketPath, base64_decode($data)); // create the file $uploadedFiles->push(File::create([ diff --git a/server/src/Http/Controllers/v1/ServiceQuoteController.php b/server/src/Http/Controllers/v1/ServiceQuoteController.php index cee388a0..fd9857d5 100644 --- a/server/src/Http/Controllers/v1/ServiceQuoteController.php +++ b/server/src/Http/Controllers/v1/ServiceQuoteController.php @@ -10,6 +10,7 @@ use Fleetbase\FleetOps\Models\ServiceQuoteItem; use Fleetbase\FleetOps\Models\ServiceRate; use Fleetbase\FleetOps\Models\Vehicle; +use Fleetbase\FleetOps\Support\DistanceMatrix; use Fleetbase\FleetOps\Support\Utils; use Fleetbase\Http\Controllers\Controller; use Fleetbase\Storefront\Http\Requests\GetServiceQuoteFromCart; @@ -49,6 +50,11 @@ public function fromCart(GetServiceQuoteFromCart $request) $all = $request->boolean('all'); $isRouteOptimized = $request->boolean('is_route_optimized', true); + // Lines whose product or food truck is gone can't be quoted (or ordered). + if ($unavailable = $this->unavailableCartItems($cart, $request->input('origin'))) { + return $unavailable; + } + if (!$origin) { return response()->error('No delivery origin!'); } @@ -202,6 +208,11 @@ public function fromCartForNetwork(GetServiceQuoteFromCart $request) $all = $request->boolean('all'); $isRouteOptimized = $request->boolean('is_route_optimized', true); + // Lines whose product, store or food truck is gone can't be quoted (or ordered). + if ($unavailable = $this->unavailableCartItems($cart, $request->input('origin'))) { + return $unavailable; + } + // make sure destination is set if (!$destination) { return response()->error('No delivery destination!'); @@ -423,11 +434,41 @@ protected function getDrivingMatrix(Place $origin, Place $destination): object } /** - * Resolve the multi-origin distance matrix used to quote a network order. + * Resolve the distance and time of a network order's route: the driver collects from each + * store, then delivers. Each leg is measured like a single store's (`getDrivingMatrix`) and + * the legs are added up. + * + * The stores are visited farthest from the customer first, then each nearest next, so the + * route ends close to the customer. (`Utils::distanceMatrix` cannot be used here: it joins + * the stores into one origin string, which the straight-line provider misreads and Google + * answers for the first store only.) */ protected function getNetworkDistanceMatrix($origins, Place $destination): object { - return Utils::distanceMatrix($origins, [$destination]); + $remaining = collect($origins)->filter()->values(); + if ($remaining->isEmpty()) { + return new DistanceMatrix(0, 0); + } + + $straightLine = fn ($from, $to) => (float) Utils::getPreliminaryDistanceMatrix($from, $to)->distance; + + $current = $remaining->sortByDesc(fn ($place) => $straightLine($place, $destination))->first(); + $remaining = $remaining->reject(fn ($place) => $place === $current)->values(); + $distance = 0; + $time = 0; + + while ($remaining->isNotEmpty()) { + $next = $remaining->sortBy(fn ($place) => $straightLine($current, $place))->first(); + $leg = $this->getDrivingMatrix($current, $next); + $distance += (float) $leg->distance; + $time += (float) $leg->time; + $current = $next; + $remaining = $remaining->reject(fn ($place) => $place === $next)->values(); + } + + $leg = $this->getDrivingMatrix($current, $destination); + + return new DistanceMatrix($distance + (float) $leg->distance, $time + (float) $leg->time); } /** @@ -440,6 +481,58 @@ protected function getServiceRates(Place $destination, string $orderConfigKey, ? }); } + /** + * A 422 naming the cart lines that can no longer be ordered, or null when every line + * is fine: the product was deleted or unpublished, or the line (or the requested + * origin) points at a food truck or store location that no longer exists. Apps show + * "some items are no longer available" instead of an address problem. + */ + protected function unavailableCartItems(?Cart $cart, string|array|null $origin = null) + { + $items = collect($cart?->items ?? []); + if ($items->isEmpty()) { + return null; + } + + $productIds = $items->pluck('product_id')->filter()->unique()->values(); + $products = Product::whereIn('public_id', $productIds)->get(['public_id', 'is_available', 'status'])->keyBy('public_id'); + $truckIds = $items->pluck('food_truck_id')->filter()->unique()->values(); + $origins = collect(is_array($origin) ? $origin : explode(',', (string) $origin))->filter(); + $truckIds = $truckIds->merge($origins->filter(fn ($id) => Str::startsWith($id, 'food_truck_')))->unique()->values(); + $trucks = $truckIds->isEmpty() ? collect() : FoodTruck::whereIn('public_id', $truckIds)->pluck('public_id'); + $locationIds = $items->pluck('store_location_id')->filter(fn ($id) => Str::startsWith((string) $id, 'store_location'))->unique()->values(); + $locations = $locationIds->isEmpty() ? collect() : StoreLocation::whereIn('public_id', $locationIds)->pluck('public_id'); + $missingTruck = $origins->first(fn ($id) => Str::startsWith($id, 'food_truck_') && !$trucks->contains($id)); + + $unavailable = $items + ->filter(function ($item) use ($products, $trucks, $locations, $missingTruck) { + $product = $products->get(data_get($item, 'product_id')); + if (!$product || $product->is_available === false || $product->status === 'draft') { + return true; + } + $truckId = data_get($item, 'food_truck_id'); + if (($truckId && !$trucks->contains($truckId)) || ($missingTruck && $truckId === $missingTruck)) { + return true; + } + $locationId = data_get($item, 'store_location_id'); + + return Str::startsWith((string) $locationId, 'store_location') && !$locations->contains($locationId); + }) + ->map(fn ($item) => ['id' => data_get($item, 'id'), 'product_id' => data_get($item, 'product_id'), 'name' => data_get($item, 'name')]) + ->values(); + + // A missing truck the app asked to deliver from, even if no line names it. + if ($unavailable->isEmpty() && !$missingTruck) { + return null; + } + + return response()->json([ + 'errors' => ['Some items in your cart are no longer available.'], + 'code' => 'cart_items_unavailable', + 'items' => $unavailable->all(), + ], 422); + } + /** * Returns a place from either a place id or store location id. */ diff --git a/server/src/Http/Requests/CreateReviewRequest.php b/server/src/Http/Requests/CreateReviewRequest.php index 470ab7bf..596a09c7 100644 --- a/server/src/Http/Requests/CreateReviewRequest.php +++ b/server/src/Http/Requests/CreateReviewRequest.php @@ -31,11 +31,14 @@ public function rules() // — as a 500, before the controller's own "Invalid subject for review" guard // could run. That guard was unreachable for the commonest way to get it wrong. return [ - 'subject' => 'required|string', - 'rating' => 'required|numeric', - 'content' => 'required', - 'files' => 'sometimes|array', - 'rejected' => 'sometimes|boolean', + 'subject' => 'required|string', + 'order' => 'sometimes|nullable|string', + 'rating' => 'required|integer|between:1,5', + 'content' => 'required|string|max:2000', + 'files' => 'sometimes|array|max:4', + 'files.*.data' => 'required_with:files|string', + 'files.*.type' => 'required_with:files|string|starts_with:image/,video/', + 'rejected' => 'sometimes|boolean', ]; } } diff --git a/server/src/Http/Requests/SendOrderChatMessageRequest.php b/server/src/Http/Requests/SendOrderChatMessageRequest.php new file mode 100644 index 00000000..edc53322 --- /dev/null +++ b/server/src/Http/Requests/SendOrderChatMessageRequest.php @@ -0,0 +1,33 @@ + 'required_without:files|nullable|string|max:2000', + 'files' => 'required_without:content|array|max:4', + 'files.*.data' => 'required_with:files|string', + 'files.*.type' => 'required_with:files|string|starts_with:image/', + ]; + } +} diff --git a/server/src/Http/Resources/FoodTruck.php b/server/src/Http/Resources/FoodTruck.php index 942e3624..028be3e6 100644 --- a/server/src/Http/Resources/FoodTruck.php +++ b/server/src/Http/Resources/FoodTruck.php @@ -36,6 +36,14 @@ public function toArray($request) 'location' => $this->vehicle ? $this->vehicle->location : null, 'online' => $this->vehicle ? $this->vehicle->online : false, 'status' => $this->status, + // The store the truck belongs to, so a network can name it and order from it. + 'store' => $this->whenLoaded('store', fn () => $this->store ? [ + 'id' => $this->store->public_id, + 'name' => $this->store->name, + 'logo_url' => $this->store->logo_url, + 'currency' => $this->store->currency, + 'options' => $this->store->options, + ] : null), 'created_at' => $this->created_at, 'updated_at' => $this->updated_at, ]; diff --git a/server/src/Http/Resources/Index/Order.php b/server/src/Http/Resources/Index/Order.php index f2907654..75ef05b9 100644 --- a/server/src/Http/Resources/Index/Order.php +++ b/server/src/Http/Resources/Index/Order.php @@ -40,9 +40,15 @@ private function storefrontOrderMeta(): array 'tip', 'delivery_tip', 'total', + 'discount', + 'promotions', + 'payment_status', 'currency', 'gateway', 'is_pickup', + 'is_booking', + 'booking_at', + 'booking_has_items', 'is_master_order', 'related_orders', 'master_order_id', diff --git a/server/src/Http/Resources/Order.php b/server/src/Http/Resources/Order.php index e6b04a8b..81b4a02a 100644 --- a/server/src/Http/Resources/Order.php +++ b/server/src/Http/Resources/Order.php @@ -49,9 +49,15 @@ private function storefrontOrderMeta(): array 'tip', 'delivery_tip', 'total', + 'discount', + 'promotions', + 'payment_status', 'currency', 'gateway', 'is_pickup', + 'is_booking', + 'booking_at', + 'booking_has_items', 'is_master_order', 'related_orders', 'master_order_id', diff --git a/server/src/Http/Resources/OrderChat.php b/server/src/Http/Resources/OrderChat.php new file mode 100644 index 00000000..e04d164e --- /dev/null +++ b/server/src/Http/Resources/OrderChat.php @@ -0,0 +1,57 @@ + $messages latest messages, oldest first + */ + public function __construct(ChatChannel $channel, public Order $order, public ?string $customerUserUuid, public iterable $messages = [], public int $unreadCount = 0) + { + parent::__construct($channel); + } + + /** + * Transform the resource into an array. + * + * @param \Illuminate\Http\Request $request + * + * @return array + */ + public function toArray($request) + { + $closed = OrderChatSupport::isClosed($this->order); + $me = $this->participants->firstWhere('user_uuid', $this->customerUserUuid); + + return [ + 'id' => $this->public_id, + // Core broadcasts chat messages on `chat.{public_id}` (and `chat.{uuid}`). + 'channel' => 'chat.' . $this->public_id, + 'order' => $this->order->public_id, + 'status' => $closed ? 'closed' : 'open', + 'me' => data_get($me, 'public_id'), + 'participants' => $this->participants->map(fn ($participant) => [ + 'id' => $participant->public_id, + 'role' => $participant->user_uuid === $this->customerUserUuid ? 'customer' : 'driver', + 'name' => data_get($participant, 'user.name'), + // Null without an uploaded avatar, so the app shows initials instead of a placeholder. + 'avatar_url' => data_get($participant, 'user.avatar_uuid') ? data_get($participant, 'user.avatar_url') : null, + 'is_online' => (bool) $participant->is_online, + ])->values()->all(), + 'messages' => OrderChatMessage::list($this->messages, $this->customerUserUuid), + 'unread_count' => $this->unreadCount, + ]; + } +} diff --git a/server/src/Http/Resources/OrderChatMessage.php b/server/src/Http/Resources/OrderChatMessage.php new file mode 100644 index 00000000..7c0e5e44 --- /dev/null +++ b/server/src/Http/Resources/OrderChatMessage.php @@ -0,0 +1,67 @@ +sender; + $senderUser = data_get($sender, 'user_uuid'); + $isMine = $senderUser !== null && $senderUser === $this->customerUserUuid; + + return [ + 'id' => $this->public_id, + 'content' => $this->content, + 'sender' => [ + 'id' => data_get($sender, 'public_id'), + 'role' => $isMine ? 'customer' : 'driver', + 'name' => data_get($sender, 'user.name'), + ], + 'is_mine' => $isMine, + 'attachments' => $this->attachments->map(fn ($attachment) => [ + 'id' => data_get($attachment, 'file.public_id'), + 'url' => data_get($attachment, 'file.url'), + 'type' => data_get($attachment, 'file.content_type'), + ])->values()->all(), + // Read by someone other than the sender. + 'read' => $this->receipts->contains(fn ($receipt) => $receipt->participant_uuid !== $this->sender_uuid), + 'created_at' => $this->created_at, + ]; + } + + /** + * @param iterable $messages + * + * @return array + */ + public static function list(iterable $messages, ?string $customerUserUuid): array + { + $items = []; + foreach ($messages as $message) { + $items[] = (new static($message, $customerUserUuid))->resolve(); + } + + return $items; + } +} diff --git a/server/src/Http/Resources/Product.php b/server/src/Http/Resources/Product.php index db403dbb..efc0a2d9 100644 --- a/server/src/Http/Resources/Product.php +++ b/server/src/Http/Resources/Product.php @@ -110,6 +110,8 @@ public function mapAddonCategories(Collection|array $addonCategories = []) 'id' => $addonCategory->id, 'name' => $addonCategory->name, 'excluded_addons' => $addonCategory->excluded_addons, + 'is_required' => (bool) $addonCategory->is_required, + 'max_selectable' => $addonCategory->max_selectable, 'category' => $addonCategory->category, 'created_at' => $addonCategory->created_at, 'updated_at' => $addonCategory->updated_at, @@ -121,6 +123,8 @@ public function mapAddonCategories(Collection|array $addonCategories = []) 'name' => data_get($addonCategory, 'name'), 'description' => data_get($addonCategory, 'category.description'), 'excluded_addons' => $addonCategory->excluded_addons, + 'is_required' => (bool) $addonCategory->is_required, + 'max_selectable' => $addonCategory->max_selectable ? (int) $addonCategory->max_selectable : null, 'addons' => $this->mapProductAddons($addons, $addonCategory->excluded_addons), ]; diff --git a/server/src/Http/Resources/Promotion.php b/server/src/Http/Resources/Promotion.php index e3d44d69..2fd8f71f 100644 --- a/server/src/Http/Resources/Promotion.php +++ b/server/src/Http/Resources/Promotion.php @@ -36,7 +36,11 @@ public function toArray($request) 'currency' => $this->currency, 'min_subtotal' => $this->min_subtotal, 'min_items' => $this->min_items, - 'applies_to' => $this->applies_to, + 'owner' => $this->when(!$internal, fn () => $this->ownerSummary()), + 'code' => $this->when(!$internal, fn () => $this->shareableCode()), + 'availability' => $this->availabilityAt(), + 'next_starts_at' => $this->nextLiveAt(), + 'applies_to' => $internal ? $this->applies_to : $this->publicAppliesTo(), 'bogo_config' => $this->bogo_config, 'first_order_only' => $this->first_order_only, 'usage_limit' => $this->when($internal, $this->usage_limit), diff --git a/server/src/Http/Resources/Review.php b/server/src/Http/Resources/Review.php index 240b212b..734e50b6 100644 --- a/server/src/Http/Resources/Review.php +++ b/server/src/Http/Resources/Review.php @@ -3,6 +3,8 @@ namespace Fleetbase\Storefront\Http\Resources; use Fleetbase\Http\Resources\FleetbaseResource; +use Fleetbase\Storefront\Models\Product; +use Fleetbase\Storefront\Support\Storefront; use Fleetbase\Support\Http; class Review extends FleetbaseResource @@ -17,17 +19,21 @@ class Review extends FleetbaseResource public function toArray($request) { return [ - 'id' => $this->when(Http::isInternalRequest(), $this->id, $this->public_id), - 'uuid' => $this->when(Http::isInternalRequest(), $this->uuid), - 'public_id' => $this->when(Http::isInternalRequest(), $this->public_id), - 'subject_id' => $this->subject->id, - 'rating' => $this->rating, - 'content' => $this->content, - 'customer' => new ReviewCustomer($this->customer), - 'slug' => $this->slug, - 'photos' => $this->mapPhotos($this->photos), - 'created_at' => $this->created_at, - 'updated_at' => $this->updated_at, + 'id' => $this->when(Http::isInternalRequest(), $this->id, $this->public_id), + 'uuid' => $this->when(Http::isInternalRequest(), $this->uuid), + 'public_id' => $this->when(Http::isInternalRequest(), $this->public_id), + 'subject_id' => $this->when(Http::isInternalRequest(), $this->subject?->id, $this->subject?->public_id), + 'subject_type' => $this->subject instanceof Product ? 'product' : 'store', + 'order_uuid' => $this->when(Http::isInternalRequest(), $this->order_uuid), + 'verified' => !empty($this->order_uuid), + 'is_mine' => $this->customer_uuid !== null && $this->customer_uuid === static::viewerCustomerUuid(), + 'rating' => $this->rating, + 'content' => $this->content, + 'customer' => new ReviewCustomer($this->customer), + 'slug' => $this->slug, + 'photos' => $this->mapPhotos($this->photos), + 'created_at' => $this->created_at, + 'updated_at' => $this->updated_at, ]; } @@ -43,4 +49,20 @@ public function mapPhotos($photos = []) ]; }); } + + /** + * The uuid of the customer making the request, looked up once per request so that a + * page of reviews does not resolve the customer token for every review. + */ + public static function viewerCustomerUuid(): ?string + { + $request = request(); + $key = 'storefront.review_viewer_uuid'; + + if (!$request->attributes->has($key)) { + $request->attributes->set($key, Http::isInternalRequest() ? null : Storefront::getCustomerFromToken()?->uuid); + } + + return $request->attributes->get($key); + } } diff --git a/server/src/Http/Resources/ReviewCustomer.php b/server/src/Http/Resources/ReviewCustomer.php index 590cd748..b8dc6143 100644 --- a/server/src/Http/Resources/ReviewCustomer.php +++ b/server/src/Http/Resources/ReviewCustomer.php @@ -17,13 +17,17 @@ class ReviewCustomer extends FleetbaseResource */ public function toArray($request) { + $internal = Http::isInternalRequest(); + + // Reviews are public: customers see each other's first name and last initial only, + // never their contact details. return [ 'id' => $this->when(Http::isInternalRequest(), $this->id, Str::replaceFirst('contact', 'customer', $this->public_id)), 'uuid' => $this->when(Http::isInternalRequest(), $this->uuid), 'public_id' => $this->when(Http::isInternalRequest(), $this->public_id), - 'name' => $this->name, - 'email' => $this->email, - 'phone' => $this->phone, + 'name' => $internal ? $this->name : static::publicName($this->name), + 'email' => $this->when($internal, $this->email), + 'phone' => $this->when($internal, $this->phone), 'photo_url' => $this->photo_url, 'reviews_count' => $this->resource->reviews()->count(), 'uploads_count' => $this->resource->reviewUploads()->count(), @@ -32,4 +36,21 @@ public function toArray($request) 'updated_at' => $this->updated_at, ]; } + + /** + * "Ada B." from "Ada Buyer"; a single name is shown as is. + */ + public static function publicName(?string $name): ?string + { + $parts = preg_split('/\s+/u', trim((string) $name), -1, PREG_SPLIT_NO_EMPTY); + if (!$parts) { + return null; + } + + if (count($parts) === 1) { + return $parts[0]; + } + + return $parts[0] . ' ' . mb_strtoupper(mb_substr(end($parts), 0, 1)) . '.'; + } } diff --git a/server/src/Http/Resources/Store.php b/server/src/Http/Resources/Store.php index f515a413..8faac538 100644 --- a/server/src/Http/Resources/Store.php +++ b/server/src/Http/Resources/Store.php @@ -50,10 +50,11 @@ public function toArray($request) 'alertable' => $this->alertable, 'is_network' => false, 'is_store' => true, - 'category' => $this->when($request->filled('network') && ($request->has('with_category') || $request->inArray('with', 'category')), new Category($this->getNetworkCategoryUsingId($request->input('network')))), - 'networks' => $this->when($request->boolean('with_networks') || $request->inArray('with', 'networks'), Network::collection($this->networks)), - 'locations' => $this->when($request->boolean('with_locations') || $request->inArray('with', 'locations'), $this->locations->mapInto(StoreLocation::class)), - 'media' => $this->when($request->boolean('with_media') || $request->inArray('with', 'media'), Media::collection($this->media)), + // Closures, so relations and lookups only run when the field is requested. + 'category' => $this->when($request->filled('network') && ($request->has('with_category') || $request->inArray('with', 'category')), fn () => new Category($this->getNetworkCategoryUsingId($request->input('network')))), + 'networks' => $this->when($request->boolean('with_networks') || $request->inArray('with', 'networks'), fn () => Network::collection($this->networks)), + 'locations' => $this->when($request->boolean('with_locations') || $request->inArray('with', 'locations'), fn () => $this->locations->mapInto(StoreLocation::class)), + 'media' => $this->when($request->boolean('with_media') || $request->inArray('with', 'media'), fn () => Media::collection($this->media)), 'slug' => $this->slug, 'created_at' => $this->created_at, 'updated_at' => $this->updated_at, diff --git a/server/src/Listeners/HandleOrderCompleted.php b/server/src/Listeners/HandleOrderCompleted.php index 43113c3b..9b622f85 100644 --- a/server/src/Listeners/HandleOrderCompleted.php +++ b/server/src/Listeners/HandleOrderCompleted.php @@ -2,6 +2,7 @@ namespace Fleetbase\Storefront\Listeners; +use Fleetbase\Storefront\Support\Storefront; use Fleetbase\FleetOps\Events\OrderCompleted; use Fleetbase\Storefront\Notifications\StorefrontOrderCompleted; use Illuminate\Contracts\Queue\ShouldQueue; @@ -23,6 +24,11 @@ public function handle(OrderCompleted $event) /** @var \Fleetbase\FleetOps\Models\Order $order */ $order = $event->getModelRecord(); + // Bookings are told about each step by the order activity observer, in booking terms. + if (Storefront::isBookingOrder($order)) { + return; + } + // if storefront order notify customer driver has been addigned if ($order->hasMeta('storefront_id')) { $order->load(['customer']); diff --git a/server/src/Listeners/HandleOrderDispatched.php b/server/src/Listeners/HandleOrderDispatched.php index b84ec3d5..967c459c 100644 --- a/server/src/Listeners/HandleOrderDispatched.php +++ b/server/src/Listeners/HandleOrderDispatched.php @@ -2,6 +2,7 @@ namespace Fleetbase\Storefront\Listeners; +use Fleetbase\Storefront\Support\Storefront; use Fleetbase\FleetOps\Events\OrderDispatched; use Fleetbase\Storefront\Notifications\StorefrontOrderReadyForPickup; use Illuminate\Contracts\Queue\ShouldQueue; @@ -23,6 +24,11 @@ public function handle(OrderDispatched $event) /** @var \Fleetbase\FleetOps\Models\Order $order */ $order = $event->getModelRecord(); + // Bookings are told about each step by the order activity observer, in booking terms. + if (Storefront::isBookingOrder($order)) { + return; + } + // notufy customer order is ready for pickup if ($order->isMeta('is_pickup')) { $order->load(['customer']); diff --git a/server/src/Listeners/HandleOrderDriverAssigned.php b/server/src/Listeners/HandleOrderDriverAssigned.php index 6585e0f7..640b969e 100644 --- a/server/src/Listeners/HandleOrderDriverAssigned.php +++ b/server/src/Listeners/HandleOrderDriverAssigned.php @@ -5,8 +5,11 @@ use Fleetbase\FleetOps\Events\OrderDriverAssigned; use Fleetbase\FleetOps\Models\Order; use Fleetbase\Storefront\Notifications\StorefrontOrderDriverAssigned; +use Fleetbase\Storefront\Support\OrderChat; +use Illuminate\Contracts\Debug\ExceptionHandler; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Queue\InteractsWithQueue; +use Illuminate\Support\Facades\Cache; class HandleOrderDriverAssigned implements ShouldQueue { @@ -33,9 +36,19 @@ public function handle(OrderDriverAssigned $event) if ($order->hasMeta('storefront_id')) { $order->load(['customer']); - if ($order->customer) { + // Assigning and dispatching can both raise this event for the same driver; the + // customer hears about each driver once (a reassignment is a new driver). + $onceKey = 'storefront:driver-assigned:' . $order->uuid . ':' . ($order->driver_assigned_uuid ?? 'none'); + if ($order->customer && Cache::add($onceKey, true, now()->addDay())) { $order->customer->notify(new StorefrontOrderDriverAssigned($order)); } + + // Start the order chat so it is waiting in the driver's chat list. + try { + OrderChat::open($order); + } catch (\Throwable $e) { + app(ExceptionHandler::class)->report($e); + } } } } diff --git a/server/src/Listeners/HandleOrderStarted.php b/server/src/Listeners/HandleOrderStarted.php index d7000b34..cfd8875d 100644 --- a/server/src/Listeners/HandleOrderStarted.php +++ b/server/src/Listeners/HandleOrderStarted.php @@ -2,6 +2,7 @@ namespace Fleetbase\Storefront\Listeners; +use Fleetbase\Storefront\Support\Storefront; use Fleetbase\FleetOps\Events\OrderStarted; use Fleetbase\Storefront\Notifications\StorefrontOrderEnroute; @@ -19,6 +20,11 @@ public function handle(OrderStarted $event) /** @var \Fleetbase\FleetOps\Models\Order $order */ $order = $event->getModelRecord(); + // Bookings are told about each step by the order activity observer, in booking terms. + if (Storefront::isBookingOrder($order)) { + return; + } + // if storefront order / notify customer driver has started and is en-route if ($order->hasMeta('storefront_id')) { $order->load(['customer']); diff --git a/server/src/Models/Cart.php b/server/src/Models/Cart.php index 6e3ca0bf..17c7e590 100644 --- a/server/src/Models/Cart.php +++ b/server/src/Models/Cart.php @@ -49,7 +49,15 @@ class Cart extends StorefrontModel * * @var array */ - protected $fillable = ['company_uuid', 'user_uuid', 'checkout_uuid', 'customer_id', 'unique_identifier', 'currency', 'discount_code', 'items', 'events', 'expires_at']; + protected $fillable = ['company_uuid', 'user_uuid', 'checkout_uuid', 'status', 'customer_id', 'unique_identifier', 'currency', 'discount_code', 'items', 'events', 'expires_at']; + + /** + * Cart statuses. Only an open cart is shopped; a checked out or cleared cart keeps its + * items as the record of what it held, and the device continues with a new open cart. + */ + public const STATUS_OPEN = 'open'; + public const STATUS_CHECKED_OUT = 'checked_out'; + public const STATUS_CLEARED = 'cleared'; /** * The attributes that should be cast to native types. @@ -660,11 +668,66 @@ public static function newCart($uniqueId = null): Cart 'expires_at' => Carbon::now()->addDays(7), 'currency' => session('storefront_currency'), 'customer_id' => session('customer_id'), + 'status' => static::STATUS_OPEN, 'items' => [], 'events' => [], ]); } + /** + * Carts still being shopped: not checked out, not cleared. + */ + public function scopeOpen($query) + { + return $query->whereNull('checkout_uuid')->where(function ($query) { + $query->whereNull('status')->orWhere('status', static::STATUS_OPEN); + }); + } + + public function isOpen(): bool + { + return $this->checkout_uuid === null && in_array($this->status, [null, static::STATUS_OPEN], true); + } + + /** + * Close this cart as cleared, keeping its items, and return the open cart the device + * continues with. An empty open cart is simply kept; a closed cart is left as it is. + */ + public function clear(): Cart + { + if ($this->isOpen()) { + if (count($this->getAttribute('items') ?? []) === 0) { + return $this; + } + + $this->createEvent('cart.cleared', null, false); + $this->status = static::STATUS_CLEARED; + $this->save(); + } + + return static::openCartFor($this->unique_identifier); + } + + /** + * The open cart of a device (its unique identifier), or a new one for it. + */ + public static function openCartFor(?string $uniqueId): Cart + { + if ($uniqueId) { + $query = static::where('unique_identifier', $uniqueId)->open(); + if (session('company')) { + $query->where('company_uuid', session('company')); + } + + $open = $query->latest()->first(); + if ($open) { + return $open; + } + } + + return static::newCart($uniqueId); + } + /** * Retrieve a cart by id or unique id. */ @@ -683,17 +746,24 @@ public static function retrieve(?string $id, bool $excludeCheckedout = true): Ca $query->where('company_uuid', session('company')); } - if ($excludeCheckedout) { - $query->whereNull('checkout_uuid'); + if (!$excludeCheckedout) { + return $query->first() ?? static::newCart(!Str::startsWith($id, 'cart_') ? $id : null); } - $cart = $query->first(); + $cart = (clone $query)->open()->first(); + if ($cart) { + return $cart; + } - if (!$cart) { - return static::newCart(!Str::startsWith($id, 'cart_') ? $id : null); + // The id may name a cart that has been checked out or cleared (an app can still + // hold it): continue with the open cart of the same device rather than starting an + // anonymous cart that no device will find again. + $closed = (clone $query)->latest()->first(); + if ($closed && $closed->unique_identifier) { + return static::openCartFor($closed->unique_identifier); } - return $cart; + return static::newCart(!Str::startsWith($id, 'cart_') ? $id : null); } /** diff --git a/server/src/Models/Checkout.php b/server/src/Models/Checkout.php index 16ef909b..d5cb4d21 100644 --- a/server/src/Models/Checkout.php +++ b/server/src/Models/Checkout.php @@ -10,6 +10,7 @@ use Fleetbase\Traits\HasOptionsAttributes; use Fleetbase\Traits\HasPublicid; use Fleetbase\Traits\HasUuid; +use Illuminate\Support\Arr; use Illuminate\Support\Str; class Checkout extends StorefrontModel @@ -75,12 +76,20 @@ public static function boot() }); } + /* + * The related models (Company, Order, Contact, ServiceQuote) choose the Fleetbase + * database themselves (Fleetbase\Models\Model::getConnectionName()). These relations + * used to call $this->setConnection(...), which switched the checkout itself to that + * database, so any later refresh or save of the checkout failed (checkouts lives in + * the storefront database) — e.g. after a QPay payment, before the order was created. + */ + /** * @return \Illuminate\Database\Eloquent\Relations\BelongsTo */ public function company() { - return $this->setConnection(config('fleetbase.connection.db'))->belongsTo(Company::class); + return $this->belongsTo(Company::class); } /** @@ -88,7 +97,7 @@ public function company() */ public function order() { - return $this->setConnection(config('fleetbase.connection.db'))->belongsTo(Order::class); + return $this->belongsTo(Order::class); } /** @@ -96,7 +105,7 @@ public function order() */ public function owner() { - return $this->setConnection(config('fleetbase.connection.db'))->morphTo(__FUNCTION__, 'owner_type', 'owner_uuid')->withoutGlobalScopes(); + return $this->morphTo(__FUNCTION__, 'owner_type', 'owner_uuid')->withoutGlobalScopes(); } /** @@ -104,7 +113,7 @@ public function owner() */ public function serviceQuote() { - return $this->setConnection(config('fleetbase.connection.db'))->belongsTo(ServiceQuote::class); + return $this->belongsTo(ServiceQuote::class); } /** @@ -163,7 +172,31 @@ public function checkedout() $cart = $this->cart; if ($cart) { - $this->cart->update(['checkout_uuid' => $this->uuid]); + $this->cart->update(['checkout_uuid' => $this->uuid, 'status' => Cart::STATUS_CHECKED_OUT]); } } + + /** + * The cart as it was when this checkout was created (cart_state): the items and prices + * that were priced and charged. Orders are created from it, so a cart changed or + * cleared between payment and order creation can't change the order. Null for older + * checkouts saved without it. The returned cart is a read-only copy, never saved. + */ + public function cartAtCheckout(): ?Cart + { + $state = json_decode(json_encode($this->cart_state ?? null), true); + if (!is_array($state) || !array_key_exists('items', $state) || !is_array($state['items'])) { + return null; + } + + $attributes = Arr::only($state, ['uuid', 'public_id', 'company_uuid', 'user_uuid', 'checkout_uuid', 'status', 'customer_id', 'unique_identifier', 'currency', 'discount_code', 'expires_at', 'created_at', 'updated_at']); + $attributes['uuid'] = $attributes['uuid'] ?? $this->cart_uuid; + $attributes['items'] = json_encode($state['items']); + $attributes['events'] = json_encode($state['events'] ?? []); + + $cart = new Cart(); + $cart->setRawAttributes($attributes, true); + + return $cart; + } } diff --git a/server/src/Models/Promotion.php b/server/src/Models/Promotion.php index 996d4b03..15335c13 100644 --- a/server/src/Models/Promotion.php +++ b/server/src/Models/Promotion.php @@ -5,6 +5,7 @@ use Fleetbase\Casts\Json; use Fleetbase\Casts\PolymorphicType; use Fleetbase\FleetOps\Support\Utils; +use Fleetbase\Models\Category; use Fleetbase\Models\File; use Fleetbase\Traits\HasApiModelBehavior; use Fleetbase\Traits\HasPublicId; @@ -36,6 +37,15 @@ class Promotion extends StorefrontModel public const STATUSES = [self::STATUS_DRAFT, self::STATUS_ACTIVE, self::STATUS_PAUSED, self::STATUS_ENDED]; + /** + * Customer-facing availability: running now, running later (outside its weekly hours or + * before its start date), over, or not offered at all (draft or paused). + */ + public const AVAILABILITY_LIVE = 'live'; + public const AVAILABILITY_SCHEDULED = 'scheduled'; + public const AVAILABILITY_ENDED = 'ended'; + public const AVAILABILITY_INACTIVE = 'inactive'; + protected $publicIdType = 'promotion'; protected $table = 'promotions'; @@ -165,6 +175,151 @@ public function isLiveAt(?Carbon $moment = null): bool return false; } + /** + * Customer-facing availability at the given moment. + */ + public function availabilityAt(?Carbon $moment = null): string + { + $moment ??= Carbon::now(); + + if ($this->status === self::STATUS_ENDED || ($this->ends_at && $moment->gte($this->ends_at))) { + return self::AVAILABILITY_ENDED; + } + + if ($this->status !== self::STATUS_ACTIVE) { + return self::AVAILABILITY_INACTIVE; + } + + return $this->isLiveAt($moment) ? self::AVAILABILITY_LIVE : self::AVAILABILITY_SCHEDULED; + } + + /** + * When a scheduled promotion next starts applying, looking up to a week ahead; null when it + * is live now, has ended, is not active, or has no start inside that week. + */ + public function nextLiveAt(?Carbon $moment = null): ?Carbon + { + $moment ??= Carbon::now(); + + if ($this->availabilityAt($moment) !== self::AVAILABILITY_SCHEDULED) { + return null; + } + + $timezone = $this->timezone ?: config('app.timezone', 'UTC'); + $from = ($this->starts_at && $this->starts_at->gt($moment) ? $this->starts_at : $moment)->copy()->setTimezone($timezone); + $windows = array_values(array_filter((array) ($this->schedule ?? []), 'is_array')); + if (empty($windows)) { + $windows = [['days' => [1, 2, 3, 4, 5, 6, 7], 'start' => '00:00', 'end' => '24:00']]; + } + + $next = null; + for ($offset = -1; $offset <= 7; $offset++) { + $day = $from->copy()->startOfDay()->addDays($offset); + foreach ($windows as $window) { + if (!in_array($day->dayOfWeekIso, array_map('intval', (array) ($window['days'] ?? [1, 2, 3, 4, 5, 6, 7])), true)) { + continue; + } + + $start = static::minutesOfDay($window['start'] ?? '00:00'); + $end = static::minutesOfDay($window['end'] ?? '24:00'); + $opens = $day->copy()->addMinutes($start); + $shuts = $day->copy()->addMinutes($end <= $start ? $end + 1440 : $end); + + $candidate = $opens->lt($from) ? $from->copy() : $opens; + if ($candidate->gte($shuts) || ($this->ends_at && $candidate->gte($this->ends_at))) { + continue; + } + + if (!$next || $candidate->lt($next)) { + $next = $candidate; + } + } + } + + return $next?->setTimezone(config('app.timezone', 'UTC')); + } + + /** + * The code a customer can type for a code-triggered promotion: an active code that is not + * assigned to one customer, has not expired and can be used more than once. Batches of + * single-use codes are handed out individually (for example by a campaign), so they are + * never shown publicly. + */ + public function shareableCode(?Carbon $moment = null): ?string + { + if ($this->trigger !== self::TRIGGER_CODE) { + return null; + } + + $moment ??= Carbon::now(); + $codes = $this->relationLoaded('codes') ? $this->codes : $this->codes()->get(); + + $code = $codes + ->filter(fn (PromotionCode $code) => $code->status === PromotionCode::STATUS_ACTIVE + && empty($code->customer_uuid) + && (!$code->expires_at || $moment->lt($code->expires_at)) + && ($code->usage_limit === null || $code->usage_limit > 1)) + ->sortByDesc('created_at') + ->first(); + + return $code?->code; + } + + /** + * The store or network running the promotion, as customers see it. + * + * @return array{type: string, id: ?string, name: ?string, logo_url: ?string}|null + */ + public function ownerSummary(): ?array + { + $owner = $this->owner; + if (!$owner instanceof Store && !$owner instanceof Network) { + return null; + } + + return [ + 'type' => $owner instanceof Store ? 'store' : 'network', + 'id' => $owner->public_id, + 'name' => $owner->name, + 'logo_url' => data_get($owner, 'logo.url'), + ]; + } + + /** + * `applies_to` with every product, category and store named by its public id, so the app + * can link "Shop the offer" to them. Ids that no longer resolve are dropped. + * + * @return array> + */ + public function publicAppliesTo(): array + { + $appliesTo = (array) ($this->applies_to ?? []); + $models = [ + 'products' => Product::class, + 'exclude_products' => Product::class, + 'stores' => Store::class, + 'categories' => Category::class, + 'exclude_categories' => Category::class, + ]; + + $public = []; + foreach ($models as $key => $model) { + $ids = array_values(array_filter((array) ($appliesTo[$key] ?? []), 'is_string')); + if (empty($ids)) { + continue; + } + + $public[$key] = $model::query() + ->where(fn ($query) => $query->whereIn('uuid', $ids)->orWhereIn('public_id', $ids)) + ->pluck('public_id') + ->filter() + ->values() + ->all(); + } + + return $public; + } + protected static function minutesOfDay(string $time): int { [$hours, $minutes] = array_pad(array_map('intval', explode(':', $time)), 2, 0); diff --git a/server/src/Models/Review.php b/server/src/Models/Review.php index 178efbd9..7ec470cd 100644 --- a/server/src/Models/Review.php +++ b/server/src/Models/Review.php @@ -2,6 +2,7 @@ namespace Fleetbase\Storefront\Models; +use Fleetbase\FleetOps\Models\Order; use Fleetbase\Models\File; use Fleetbase\Models\User; use Fleetbase\Traits\HasApiModelBehavior; @@ -45,6 +46,7 @@ class Review extends StorefrontModel protected $fillable = [ 'created_by_uuid', 'customer_uuid', + 'order_uuid', 'subject_uuid', 'subject_type', 'rating', @@ -57,7 +59,9 @@ class Review extends StorefrontModel * * @var array */ - protected $casts = []; + protected $casts = [ + 'rating' => 'integer', + ]; /** * Dynamic attributes that are appended to object. @@ -89,6 +93,14 @@ public function customer() return $this->setConnection(config('fleetbase.connection.db'))->belongsTo(Customer::class); } + /** + * The completed order this review was written for. + */ + public function order() + { + return $this->belongsTo(Order::class, 'order_uuid', 'uuid'); + } + /** * @return \Illuminate\Database\Eloquent\Relations\HasMany */ diff --git a/server/src/Models/Store.php b/server/src/Models/Store.php index 4c5a3c2d..1dce9319 100644 --- a/server/src/Models/Store.php +++ b/server/src/Models/Store.php @@ -340,6 +340,14 @@ public function getBackdropUrlAttribute() */ public function getRatingAttribute() { + // Use the average preloaded with withAvg('reviews', 'rating') when present, so + // listing stores does not run one aggregate query per store. + if (array_key_exists('reviews_avg_rating', $this->attributes)) { + $average = $this->attributes['reviews_avg_rating']; + + return is_numeric($average) ? $average + 0 : 0; + } + return $this->reviews()->avg('rating') ?? 0; } @@ -356,13 +364,33 @@ public function getNetworkCategoryUsingId(?string $id) return null; } - $network = Network::where('uuid', $id)->orWhere('public_id', $id)->first(); + // A network's uuid never changes, so resolve each id once instead of once per store. + if (!array_key_exists($id, static::$networkUuidsById)) { + static::$networkUuidsById[$id] = Network::where('uuid', $id)->orWhere('public_id', $id)->value('uuid'); + } - if (!$network instanceof Network) { + $networkUuid = static::$networkUuidsById[$id]; + + if (!$networkUuid) { return null; } - return $this->getNetworkCategory($network); + return $this->getNetworkCategory((new Network())->forceFill(['uuid' => $networkUuid])); + } + + /** + * Network uuids resolved by uuid or public id. + * + * @var array + */ + protected static array $networkUuidsById = []; + + /** + * Forget network ids resolved by getNetworkCategoryUsingId(). + */ + public static function flushResolvedNetworkIds(): void + { + static::$networkUuidsById = []; } /** @@ -374,8 +402,11 @@ public function getNetworkCategoryUsingId(?string $id) */ public function getNetworkCategory(Network $network) { - // Find the relationship between this store and the given network - $networkRelation = $this->networks()->where('networks.uuid', $network->uuid)->first(); + // Find the relationship between this store and the given network, using the + // eager-loaded networks when available. + $networkRelation = $this->relationLoaded('networks') + ? $this->networks->firstWhere('uuid', $network->uuid) + : $this->networks()->where('networks.uuid', $network->uuid)->first(); // Check if the relationship exists if ($networkRelation) { diff --git a/server/src/Notifications/StorefrontOrderActivity.php b/server/src/Notifications/StorefrontOrderActivity.php new file mode 100644 index 00000000..2b840c31 --- /dev/null +++ b/server/src/Notifications/StorefrontOrderActivity.php @@ -0,0 +1,22 @@ +subject = $label; + $this->body = $details ?: 'Your order from ' . $this->storefront->name . ' has been updated: ' . $label . '.'; + $this->status = 'order_activity'; + } +} diff --git a/server/src/Notifications/StorefrontOrderChatMessage.php b/server/src/Notifications/StorefrontOrderChatMessage.php new file mode 100644 index 00000000..a4d37073 --- /dev/null +++ b/server/src/Notifications/StorefrontOrderChatMessage.php @@ -0,0 +1,99 @@ +message, 'sender.user.name') ?: 'Your driver'; + + return $name . ' sent a message'; + } + + public function body(): string + { + $content = trim((string) $this->message->content); + + return $content !== '' ? $content : 'Sent a photo'; + } + + public function toPush($notifiable): ?PushMessage + { + return PushMessage::create($this->title(), $this->body(), $this->payload())->analyticsLabel('storefront_order_chat'); + } + + public function pushStorefronts(): array + { + return PushCredentialResolver::storefrontsForOrder($this->order); + } + + public function toArray($notifiable): array + { + return [ + 'title' => $this->title(), + 'body' => $this->body(), + 'subject' => $this->title(), + 'message' => $this->body(), + ...$this->payload(), + ]; + } + + public function toBroadcast($notifiable): BroadcastMessage + { + return new BroadcastMessage(CustomerNotificationPresenter::present($this->toArray($notifiable), static::class)); + } + + public function broadcastType(): string + { + return 'order_chat_message'; + } + + /** + * @return array + */ + protected function payload(): array + { + return array_filter([ + 'type' => 'order_chat_message', + 'order' => $this->order->uuid, + 'order_id' => $this->order->public_id, + 'chat_id' => data_get($this->message, 'chatChannel.public_id'), + 'message_id' => $this->message->public_id, + 'store_id' => $this->order->getMeta('storefront_id'), + 'network_id' => $this->order->getMeta('storefront_network_id'), + ], fn ($value) => $value !== null); + } +} diff --git a/server/src/Notifications/StorefrontOrderCreated.php b/server/src/Notifications/StorefrontOrderCreated.php index 06b05192..1dd5439d 100644 --- a/server/src/Notifications/StorefrontOrderCreated.php +++ b/server/src/Notifications/StorefrontOrderCreated.php @@ -8,14 +8,17 @@ use Fleetbase\Storefront\Support\Storefront; use Fleetbase\Support\Utils; use Illuminate\Bus\Queueable; +use Illuminate\Queue\SerializesModels; +use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Notifications\Messages\MailMessage; use Illuminate\Notifications\Notification; use NotificationChannels\Twilio\TwilioChannel; use NotificationChannels\Twilio\TwilioSmsMessage; -class StorefrontOrderCreated extends Notification +class StorefrontOrderCreated extends Notification implements ShouldQueue { use Queueable; + use SerializesModels; /** * The order instance this notification is for. diff --git a/server/src/Notifications/StorefrontOrderDriverAssigned.php b/server/src/Notifications/StorefrontOrderDriverAssigned.php index 9f89deed..469e2ff4 100644 --- a/server/src/Notifications/StorefrontOrderDriverAssigned.php +++ b/server/src/Notifications/StorefrontOrderDriverAssigned.php @@ -4,6 +4,7 @@ use Fleetbase\FleetOps\Models\Driver; use Fleetbase\FleetOps\Models\Order; +use Fleetbase\Storefront\Support\Storefront; class StorefrontOrderDriverAssigned extends StorefrontOrderNotification { @@ -22,8 +23,13 @@ public function __construct(Order $order) parent::__construct($order); $this->driver = $order->driverAssigned; - $this->subject = 'Your driver is ' . $this->driver->name; - $this->body = 'A driver has been assigned to your order from ' . $this->storefront->name . ', they are currently en-route for pickup'; + if (Storefront::isBookingOrder($order)) { + $this->subject = 'Your provider is ' . $this->driver->name; + $this->body = $this->driver->name . ' will be your provider for your booking with ' . $this->storefront->name . '.'; + } else { + $this->subject = 'Your driver is ' . $this->driver->name; + $this->body = 'A driver has been assigned to your order from ' . $this->storefront->name . ', they are currently en-route for pickup'; + } $this->status = 'order_driver_assigned'; } } diff --git a/server/src/Notifications/StorefrontOrderNotification.php b/server/src/Notifications/StorefrontOrderNotification.php index 22e2f6dd..d254e7ca 100644 --- a/server/src/Notifications/StorefrontOrderNotification.php +++ b/server/src/Notifications/StorefrontOrderNotification.php @@ -14,6 +14,8 @@ use Fleetbase\Storefront\Support\NotificationPreferences; use Fleetbase\Storefront\Support\Storefront; use Illuminate\Bus\Queueable; +use Illuminate\Queue\SerializesModels; +use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Notifications\Messages\BroadcastMessage; use Illuminate\Notifications\Messages\MailMessage; use Illuminate\Notifications\Notification; @@ -21,9 +23,10 @@ /** * Base class for order lifecycle notifications sent to storefront customers. */ -abstract class StorefrontOrderNotification extends Notification implements SendsPushNotification +abstract class StorefrontOrderNotification extends Notification implements SendsPushNotification, ShouldQueue { use Queueable; + use SerializesModels; /** * The order instance this notification is for. diff --git a/server/src/Observers/ChatMessageObserver.php b/server/src/Observers/ChatMessageObserver.php new file mode 100644 index 00000000..024bab77 --- /dev/null +++ b/server/src/Observers/ChatMessageObserver.php @@ -0,0 +1,37 @@ +chatChannel; + if (!$channel || !data_get($channel->meta, OrderChat::META_ORDER_UUID)) { + return; + } + + $order = OrderChat::orderFor($channel); + if (!$order) { + return; + } + + $customer = $order->customer; + $sender = $message->sender; + if (!$customer || !$sender || $sender->user_uuid === data_get($customer, 'user_uuid')) { + return; + } + + $customer->notify(new StorefrontOrderChatMessage($message, $order)); + } +} diff --git a/server/src/Observers/OrderActivityObserver.php b/server/src/Observers/OrderActivityObserver.php new file mode 100644 index 00000000..b3b80c31 --- /dev/null +++ b/server/src/Observers/OrderActivityObserver.php @@ -0,0 +1,67 @@ +wasChanged('status') || !$order->hasMeta('storefront_id')) { + return; + } + + // Bookings have no dedicated step notifications (those speak of deliveries), so every + // step after the request is announced here. + $status = (string) $order->status; + $skipped = Storefront::isBookingOrder($order) ? ['created', 'pending'] : static::NOTIFIED_ELSEWHERE; + if ($status === '' || in_array($status, $skipped, true)) { + return; + } + + $orderUuid = $order->uuid; + + // After the change is committed, so the step's tracking status is recorded too. + dispatch(function () use ($orderUuid, $status) { + OrderActivityObserver::notify($orderUuid, $status); + })->afterCommit(); + } + + public static function notify(string $orderUuid, string $status): void + { + // An activity update can save the order more than once; each step is told once. + if (!Cache::add('storefront:order-activity:' . $orderUuid . ':' . $status, true, now()->addDay())) { + return; + } + + try { + $order = Order::where('uuid', $orderUuid)->with(['customer'])->first(); + if (!$order || !$order->customer || $order->status !== $status) { + return; + } + + $flow = OrderActivityFlow::forOrder($order); + $step = collect($flow['steps'] ?? [])->firstWhere('code', $status); + $label = data_get($step, 'label') ?: ucfirst(str_replace('_', ' ', $status)); + + $order->customer->notify(new StorefrontOrderActivity($order, $label, data_get($step, 'details') ?: null)); + } catch (\Throwable $e) { + app(ExceptionHandler::class)->report($e); + } + } +} diff --git a/server/src/Promotions/PromotionRedemptions.php b/server/src/Promotions/PromotionRedemptions.php index e0be01d1..ec3f07ba 100644 --- a/server/src/Promotions/PromotionRedemptions.php +++ b/server/src/Promotions/PromotionRedemptions.php @@ -90,6 +90,17 @@ public static function redeem(Checkout $checkout, Order $order): void } } + /** + * Release a checkout's reserved promotion uses, so the checkout can be priced and + * reserved again (the customer changed how they pay before capturing). + */ + public static function releaseFor(Checkout $checkout): int + { + return PromotionRedemption::where('checkout_uuid', $checkout->uuid) + ->where('status', PromotionRedemption::STATUS_RESERVED) + ->update(['status' => PromotionRedemption::STATUS_RELEASED]); + } + /** * Release reservations of checkouts that were never captured. */ diff --git a/server/src/Providers/StorefrontServiceProvider.php b/server/src/Providers/StorefrontServiceProvider.php index 8fd668d9..937912b0 100644 --- a/server/src/Providers/StorefrontServiceProvider.php +++ b/server/src/Providers/StorefrontServiceProvider.php @@ -4,6 +4,8 @@ use Fleetbase\FleetOps\Providers\FleetOpsServiceProvider; use Fleetbase\Providers\CoreServiceProvider; +use Fleetbase\Storefront\Support\StorefrontSocket; +use Fleetbase\Support\SocketCluster\SocketChannelRegistry; // These dependency guards are only reachable before Composer can load this provider. // The test runtime necessarily has both parent providers loaded, so the throw paths cannot execute. @@ -33,6 +35,8 @@ class StorefrontServiceProvider extends CoreServiceProvider \Fleetbase\Storefront\Models\Catalog::class => \Fleetbase\Storefront\Observers\CatalogObserver::class, \Fleetbase\Storefront\Models\FoodTruck::class => \Fleetbase\Storefront\Observers\FoodTruckObserver::class, \Fleetbase\Models\Company::class => \Fleetbase\Storefront\Observers\CompanyObserver::class, + \Fleetbase\Models\ChatMessage::class => \Fleetbase\Storefront\Observers\ChatMessageObserver::class, + \Fleetbase\FleetOps\Models\Order::class => \Fleetbase\Storefront\Observers\OrderActivityObserver::class, ]; /** @@ -111,5 +115,15 @@ public function boot() $this->mergeConfigFrom(__DIR__ . '/../../config/database.connections.php', 'database.connections'); $this->mergeConfigFrom(__DIR__ . '/../../config/storefront.php', 'storefront'); $this->mergeConfigFrom(__DIR__ . '/../../config/api.php', 'storefront.api'); + $this->registerStorefrontSocketChannels(); + } + + /** + * Registers the realtime channel prefixes storefront owns (`storefront`, `checkout`) + * with core-api's socket channel registry, so the socket server can authorize them. + */ + public function registerStorefrontSocketChannels(): void + { + StorefrontSocket::registerChannels($this->app->make(SocketChannelRegistry::class)); } } diff --git a/server/src/Support/OrderActivityFlow.php b/server/src/Support/OrderActivityFlow.php new file mode 100644 index 00000000..cc4ba0cf --- /dev/null +++ b/server/src/Support/OrderActivityFlow.php @@ -0,0 +1,198 @@ +getConnectionName()); + $config = $order->order_config_uuid ? $db->table('order_configs')->where('uuid', $order->order_config_uuid)->first(['public_id', 'key', 'name', 'flow']) : null; + $flow = $config ? (json_decode((string) $config->flow, true) ?: []) : []; + $history = $order->tracking_number_uuid + ? $db->table('tracking_statuses') + ->where('tracking_number_uuid', $order->tracking_number_uuid) + ->whereNull('deleted_at') + ->orderBy('created_at') + ->get(['code', 'status', 'created_at']) + ->map(fn ($status) => ['code' => $status->code, 'label' => $status->status, 'at' => $status->created_at ? Carbon::parse($status->created_at)->toIso8601String() : null]) + ->all() + : []; + $tokens = ['storefront' => ['name' => $order->getMeta('storefront')]]; + $passes = function (array $activity) use ($order, $flow): bool { + try { + return (new Activity($activity, $flow))->passes($order); + } catch (\Throwable $e) { + return false; + } + }; + + return [ + 'order' => $order->public_id, + 'order_config' => $config ? ['id' => $config->public_id, 'key' => $config->key, 'name' => $config->name] : null, + ...static::resolve($flow, $order->status, $history, $passes, $order->created_at ? Carbon::parse($order->created_at)->toIso8601String() : null, $tokens), + ]; + } + + /** + * @param array $flow the order config's activities, keyed by code + * @param string|null $status the order's current status code + * @param array $history tracking statuses, oldest first: ['code', 'label', 'at'] + * @param callable $passes whether an activity's conditions hold for the order + * @param string|null $createdAt when the order was placed + * @param array $tokens values for `{placeholders}` in activity details + */ + public static function resolve(array $flow, ?string $status, array $history, callable $passes, ?string $createdAt = null, array $tokens = []): array + { + $activities = []; + foreach ($flow as $key => $activity) { + if (is_array($activity)) { + $code = static::code($activity['code'] ?? $key); + $activities[$code] = $activity; + } + } + + // The path so far: each activity once, in the order it was first reached. + $path = []; + $reached = []; + foreach ($history as $entry) { + $code = static::code($entry['code'] ?? null); + if ($code === '' || isset($reached[$code])) { + continue; + } + $reached[$code] = $entry['at'] ?? null; + $path[] = ['code' => $code, 'label' => $entry['label'] ?? null]; + } + if (isset($activities['created']) && !isset($reached['created'])) { + array_unshift($path, ['code' => 'created', 'label' => null]); + $reached['created'] = $createdAt; + } + + $current = static::code($status); + if ($current !== '' && !isset($reached[$current])) { + $path[] = ['code' => $current, 'label' => null]; + $reached[$current] = null; + } + $currentIndex = $current === '' ? count($path) - 1 : array_search($current, array_column($path, 'code'), true); + + $canceled = $current !== '' && static::isCancellation($current, $activities[$current] ?? []); + $completed = !$canceled && static::isComplete($activities[$current] ?? []); + + // Where the order is expected to go next. + $upcoming = []; + if (!$canceled && !$completed) { + $visited = array_flip(array_column($path, 'code')); + $cursor = $activities[$current] ?? null; + while ($cursor && count($upcoming) < static::MAX_PROJECTED) { + $next = static::nextActivity($cursor, $activities, $visited, $passes); + if (!$next) { + break; + } + $code = static::code($next['code']); + $visited[$code] = true; + $upcoming[] = ['code' => $code, 'label' => null]; + $cursor = static::isComplete($next) ? null : $next; + } + } + + $steps = []; + foreach (array_merge($path, $upcoming) as $index => $step) { + $activity = $activities[$step['code']] ?? []; + $state = $index < $currentIndex ? 'done' : ($index === $currentIndex ? ($completed ? 'done' : 'current') : 'upcoming'); + $steps[] = [ + 'code' => $step['code'], + 'label' => $activity['status'] ?? $step['label'] ?? static::humanize($step['code']), + 'details' => isset($activity['details']) ? static::fill((string) $activity['details'], $tokens) : null, + 'state' => $state, + 'reached_at' => $state === 'upcoming' ? null : ($reached[$step['code']] ?? null), + 'complete' => static::isComplete($activity), + ]; + } + + return ['status' => $current ?: null, 'canceled' => $canceled, 'completed' => $completed, 'steps' => $steps]; + } + + /** + * The activity this order is expected to take after `$activity`: among the following + * activities it hasn't been through, not cancellations, whose conditions pass, one + * with conditions (it was chosen for this kind of order) before one without. + */ + protected static function nextActivity(array $activity, array $activities, array $visited, callable $passes): ?array + { + $candidates = []; + foreach ((array) ($activity['activities'] ?? []) as $nextCode) { + $code = static::code($nextCode); + $next = $activities[$code] ?? null; + if (!$next || isset($visited[$code]) || static::isCancellation($code, $next) || !$passes($next)) { + continue; + } + $candidates[] = $next; + } + + foreach ($candidates as $candidate) { + if (!empty($candidate['logic'])) { + return $candidate; + } + } + + return $candidates[0] ?? null; + } + + protected static function isCancellation(string $code, array $activity): bool + { + return in_array($code, ['canceled', 'cancelled', 'order_canceled'], true) || in_array('order.canceled', (array) ($activity['events'] ?? []), true); + } + + protected static function isComplete(array $activity): bool + { + return filter_var($activity['complete'] ?? false, FILTER_VALIDATE_BOOLEAN); + } + + protected static function code(mixed $code): string + { + return strtolower(trim((string) $code)); + } + + protected static function humanize(string $code): string + { + return ucfirst(str_replace('_', ' ', $code)); + } + + /** Fill `{storefront.name}`-style placeholders; ones without a value are dropped. */ + protected static function fill(string $text, array $tokens): string + { + $filled = preg_replace_callback('/\{([a-z0-9_.]+)\}/i', function ($match) use ($tokens) { + $value = data_get($tokens, $match[1]); + + return is_scalar($value) ? (string) $value : ''; + }, $text); + + return trim(preg_replace('/\s{2,}/', ' ', $filled)); + } +} diff --git a/server/src/Support/OrderChat.php b/server/src/Support/OrderChat.php new file mode 100644 index 00000000..c204b090 --- /dev/null +++ b/server/src/Support/OrderChat.php @@ -0,0 +1,137 @@ +company_uuid) + ->where('meta->' . static::META_ORDER_UUID, $order->uuid) + ->first(); + } + + /** + * The order's chat, started when needed, with the customer and the assigned driver as its + * only participants. Null when either side has no user account yet, typically because no + * driver has been assigned. + */ + public static function open(Order $order): ?ChatChannel + { + $customerUserUuid = static::customerUserUuid($order); + $driverUserUuid = static::driverUserUuid($order); + if (!$customerUserUuid || !$driverUserUuid) { + return null; + } + + $channel = static::find($order); + if (!$channel) { + // Creating the channel adds its creator, the customer, as the first participant. + $channel = ChatChannel::create([ + 'company_uuid' => $order->company_uuid, + 'created_by_uuid' => $customerUserUuid, + 'name' => 'Order ' . $order->public_id, + 'meta' => [ + static::META_ORDER_UUID => $order->uuid, + static::META_ORDER_ID => $order->public_id, + ], + ]); + } + + static::syncParticipants($channel, [$customerUserUuid, $driverUserUuid]); + + return $channel; + } + + /** + * Whether the customer can still send messages. + */ + public static function isClosed(Order $order): bool + { + return in_array($order->status, static::CLOSED_STATUSES, true); + } + + /** + * The channel participant for a user, if they take part. + */ + public static function participantFor(ChatChannel $channel, ?string $userUuid): ?ChatParticipant + { + if (!$userUuid) { + return null; + } + + return ChatParticipant::where('chat_channel_uuid', $channel->uuid)->where('user_uuid', $userUuid)->first(); + } + + /** + * The order a chat channel belongs to, when it is an order chat. + */ + public static function orderFor(ChatChannel $channel): ?Order + { + $orderUuid = data_get($channel->meta, static::META_ORDER_UUID); + + return $orderUuid ? Order::where('uuid', $orderUuid)->first() : null; + } + + public static function customerUserUuid(Order $order): ?string + { + return data_get($order->customer, 'user_uuid'); + } + + public static function driverUserUuid(Order $order): ?string + { + return data_get($order->driverAssigned, 'user_uuid'); + } + + /** + * Add missing participants and remove anyone else, such as a driver the order was taken + * away from, so a reassigned order's chat moves to the new driver. + * + * @param array $userUuids + */ + protected static function syncParticipants(ChatChannel $channel, array $userUuids): void + { + $existing = ChatParticipant::where('chat_channel_uuid', $channel->uuid)->get(); + + foreach ($existing as $participant) { + if (!in_array($participant->user_uuid, $userUuids, true)) { + $participant->delete(); + } + } + + $present = $existing->pluck('user_uuid')->all(); + foreach ($userUuids as $userUuid) { + if (!in_array($userUuid, $present, true)) { + ChatParticipant::create([ + 'company_uuid' => $channel->company_uuid, + 'chat_channel_uuid' => $channel->uuid, + 'user_uuid' => $userUuid, + ]); + } + } + } +} diff --git a/server/src/Support/QPay.php b/server/src/Support/QPay.php index bb38d4b9..69f419ce 100644 --- a/server/src/Support/QPay.php +++ b/server/src/Support/QPay.php @@ -8,6 +8,7 @@ use Fleetbase\Storefront\Models\Product; use Fleetbase\Support\Utils; use GuzzleHttp\Client; +use Illuminate\Support\Facades\Cache; use Illuminate\Support\Str; /** @@ -42,6 +43,23 @@ class QPay */ private array $requestOptions = []; + /** + * Merchant credentials (client id and secret), for requesting access tokens. + */ + private ?string $username = null; + + private ?string $password = null; + + /** + * The cache key of the access token in use, when it came from the cache. + */ + private ?string $cachedTokenKey = null; + + /** + * Seconds before QPay's expiry at which a cached token is no longer used. + */ + private const TOKEN_EXPIRY_MARGIN = 300; + /** * The Guzzle HTTP client instance. */ @@ -124,6 +142,8 @@ public function __construct(?string $username = null, ?string $password = null, // checkout id, so callers must provide a full callback URL via setCallback() // or the constructor for payments to be captured $this->callbackUrl = $callbackUrl ?? static::callbackUrl(); + $this->username = $username; + $this->password = $password; $this->requestOptions = [ 'base_uri' => $this->buildRequestUrl(), 'auth' => [$username, $password], @@ -251,6 +271,16 @@ private function request(string $method, string $path, array $options = []) $options['http_errors'] = false; $response = $this->client->request($method, $path, $options); + + // A cached token QPay no longer accepts (e.g. revoked early): forget it, request a + // new one and try once more. + if ($response->getStatusCode() === 401 && $this->cachedTokenKey && $path !== 'auth/token') { + Cache::forget($this->cachedTokenKey); + $this->cachedTokenKey = null; + $this->mintAuthToken(); + $response = $this->client->request($method, $path, $options); + } + $body = $response->getBody(); $contents = $body->getContents(); $json = json_decode($contents); @@ -352,21 +382,68 @@ public function setAuthToken(?string $accessToken = null): QPay return $this; } - // Always mint a fresh token. QPay returns `expires_in` as an absolute UNIX - // timestamp, not a lifetime in seconds, so caching keyed off it cached the token - // for decades and every call kept sending a token QPay had already expired — - // answering NO_CREDENTIALS with perfectly valid credentials. Reuse is worth one - // round trip only if the real expiry is honoured; until then, correctness wins. - $response = $this->getAuthToken(); - $token = data_get($response, 'access_token'); + // QPay asks merchants not to request tokens again and again before they expire. + // A token is reused, per merchant and environment, until shortly before its expiry. + // `expires_in` is the expiry as an absolute UNIX timestamp (QPay API v2, token), not a + // lifetime in seconds; treating it as seconds once cached tokens for decades. + $key = $this->tokenCacheKey(); + $cached = Cache::get($key); + if (is_array($cached) && !empty($cached['token']) && (int) ($cached['expires_at'] ?? 0) - self::TOKEN_EXPIRY_MARGIN > time()) { + $this->cachedTokenKey = $key; + $this->useBearerToken($cached['token']); + + return $this; + } + + return $this->mintAuthToken(); + } + + /** + * Request a new access token with the merchant credentials and keep it until shortly + * before it expires. + */ + private function mintAuthToken(): QPay + { + // The token request authenticates with the credentials (Basic), not a bearer token. + $headers = $this->requestOptions['headers'] ?? []; + unset($headers['Authorization']); + $this->requestOptions['headers'] = $headers; + $this->updateRequestOption('auth', [$this->username, $this->password]); + + $response = $this->getAuthToken(); + $token = data_get($response, 'access_token'); + $expiresAt = (int) data_get($response, 'expires_in', 0); + + if (!$token) { + return $this; + } + + $this->useBearerToken($token); - if ($token) { - $this->useBearerToken($token); + $ttl = $expiresAt - time() - self::TOKEN_EXPIRY_MARGIN; + if ($ttl > 0) { + $key = $this->tokenCacheKey(); + Cache::put($key, ['token' => $token, 'expires_at' => $expiresAt], $ttl); + $this->cachedTokenKey = $key; } return $this; } + private function tokenCacheKey(): string + { + return 'storefront:qpay:token:' . sha1($this->host . '|' . $this->username . '|' . $this->password); + } + + /** + * The total a set of invoice lines asks for: quantity times unit price, summed + * (unit prices include VAT; a discount is a negative line). + */ + public static function linesTotal(array $lines): float + { + return round(array_reduce($lines, fn ($total, $line) => $total + (float) data_get($line, 'line_quantity', 0) * (float) data_get($line, 'line_unit_price', 0), 0.0), 2); + } + /** * Create a simple invoice without eBarimt (electronic receipt). * @@ -635,19 +712,27 @@ public static function mockEbarimtResponse(): array /** * Calculate VAT (Value Added Tax) from a total amount. * - * Assumes 10% VAT rate is included in the amount. Calculates the VAT portion - * by dividing by 1.1 and multiplying by 0.10, then truncates to 4 decimal places. + * Assumes 10% VAT rate is included in the amount: VAT is the amount divided by 11, + * truncated to 4 decimal places, as in QPay's e-barimt examples (50.00 → 4.5454, + * 100.00 → 9.0909, 1000.00 → 90.909, 2000.00 → 181.8181). + * + * Computed in whole units of 0.0001 with integers. Floating point gave e.g. + * 3190 / 1.1 * 0.1 = 289.99999…, truncated to 289.9999 instead of 290, which QPay + * rejects with VAT_AMOUNT_INVALID. * - * @param float|int $amount The total amount including VAT + * @param float|int|string $amount The total amount including VAT (up to 2 decimals) * * @return float The calculated VAT amount truncated to 4 decimal places */ public static function calculateTax($amount): float { - $result = ((float) $amount / 1.1) * 0.10; - $truncated = floor($result * 10000) / 10000; + // Amounts carry at most 2 decimals, so cents are exact once rounded. + $cents = (int) round((float) $amount * 100); + $negative = $cents < 0; + // VAT in ten-thousandths: cents × 100 / 11, truncated toward zero. + $vat = intdiv(abs($cents) * 100, 11); - return $truncated; + return ($negative ? -$vat : $vat) / 10000; } /** diff --git a/server/src/Support/ReviewEligibility.php b/server/src/Support/ReviewEligibility.php new file mode 100644 index 00000000..7c7fd966 --- /dev/null +++ b/server/src/Support/ReviewEligibility.php @@ -0,0 +1,136 @@ + + */ + public const MESSAGES = [ + self::SIGN_IN_REQUIRED => 'Sign in to write a review.', + self::UNSUPPORTED_SUBJECT => 'Only stores and products can be reviewed.', + self::NO_COMPLETED_ORDER => 'You can review this after an order with it is completed.', + self::ALREADY_REVIEWED => 'You have already reviewed this for your completed orders.', + ]; + + public function __construct( + public bool $allowed, + public ?string $reason = null, + public ?Order $order = null, + public ?Review $review = null, + ) { + } + + /** + * Check whether the customer can review the subject, optionally for one specific order. + * + * When no order is given, the most recent completed order that has not been reviewed for + * this subject is used. + */ + public static function check(?Contact $customer, mixed $subject, ?string $orderId = null): self + { + if (!$customer) { + return new self(false, self::SIGN_IN_REQUIRED); + } + + $query = static::completedOrdersFor($customer, $subject); + if (!$query) { + return new self(false, self::UNSUPPORTED_SUBJECT); + } + + if ($orderId) { + $query->where(fn ($orderQuery) => $orderQuery->where('public_id', $orderId)->orWhere('uuid', $orderId)); + } + + $orders = $query->orderByDesc('created_at')->limit(static::ORDER_LOOKBACK)->get(); + if ($orders->isEmpty()) { + return new self(false, self::NO_COMPLETED_ORDER); + } + + $reviews = Review::where('customer_uuid', $customer->uuid) + ->where('subject_uuid', $subject->uuid) + ->whereIn('order_uuid', $orders->pluck('uuid')) + ->get() + ->keyBy('order_uuid'); + + $unreviewed = $orders->first(fn ($order) => !$reviews->has($order->uuid)); + if (!$unreviewed) { + $latest = $orders->first(); + + return new self(false, self::ALREADY_REVIEWED, $latest, $reviews->get($latest->uuid)); + } + + return new self(true, null, $unreviewed); + } + + /** + * The customer's completed orders that include the subject, or null when the subject + * cannot be reviewed. + */ + public static function completedOrdersFor(Contact $customer, mixed $subject): ?Builder + { + $query = Order::query() + ->where('customer_uuid', $customer->uuid) + ->whereIn('status', static::COMPLETED_STATUSES); + + if ($subject instanceof Store) { + return $query->where('meta->storefront_id', $subject->public_id); + } + + if ($subject instanceof Product) { + return $query->whereIn( + 'payload_uuid', + Entity::query()->select('payload_uuid')->where('internal_id', $subject->public_id)->whereNotNull('payload_uuid') + ); + } + + return null; + } + + public function message(): ?string + { + return $this->reason ? static::MESSAGES[$this->reason] : null; + } + + /** + * @return array{can_review: bool, reason: ?string, message: ?string, order: ?string, review: ?string} + */ + public function toArray(): array + { + return [ + 'can_review' => $this->allowed, + 'reason' => $this->reason, + 'message' => $this->message(), + 'order' => $this->order?->public_id, + 'review' => $this->review?->public_id, + ]; + } +} diff --git a/server/src/Support/Storefront.php b/server/src/Support/Storefront.php index 98cf68fe..a75c050d 100644 --- a/server/src/Support/Storefront.php +++ b/server/src/Support/Storefront.php @@ -33,6 +33,12 @@ class Storefront private const CONFIG_NS = 'system:order-config:storefront'; /** @var array In-memory cache keyed by company UUID */ private static array $configCache = []; + /** @var string */ + public const BOOKING_CONFIG_KEY = 'storefront_booking'; + /** @var string */ + public const BOOKING_CONFIG_NS = 'system:order-config:storefront-booking'; + /** @var array In-memory cache of booking configs keyed by company UUID */ + private static array $bookingConfigCache = []; /** * Returns current store or network based on session `storefront_key` @@ -102,11 +108,15 @@ public static function getStoreFromLocation(string $id, $columns = [], $with = [ $columns = array_merge(['uuid', 'public_id', 'company_uuid', 'backdrop_uuid', 'logo_uuid', 'order_config_uuid', 'name', 'description', 'translations', 'website', 'facebook', 'instagram', 'twitter', 'email', 'phone', 'tags', 'currency', 'timezone', 'pod_method', 'options'], $columns); } - return Store::select($columns)->with($with)->whereHas('locations', function ($q) use ($id) { - $q->where('place_uuid', $id); - $q->orWhereHas('place', function ($q) use ($id) { - $q->where('public_id', $id); - }); + // Places live in the Fleet-Ops database and store locations in Storefront's, so the place + // is resolved on its own (a join across the two databases fails) before the store lookup. + $placeUuid = Str::isUuid($id) ? $id : \Fleetbase\FleetOps\Models\Place::where('public_id', $id)->value('uuid'); + if (!$placeUuid) { + return null; + } + + return Store::select($columns)->with($with)->whereHas('locations', function ($q) use ($placeUuid) { + $q->where('place_uuid', $placeUuid); })->first(); } @@ -574,6 +584,108 @@ public static function createStorefrontConfig(Company|string $company): OrderCon ); } + /** + * Whether an order is a service booking: it holds at least one booked service, and any + * products in it come with the (earliest) appointment. + */ + public static function isBookingOrder($order): bool + { + return $order instanceof Order && $order->isMeta('is_booking'); + } + + /** + * Get (or lazily create) the Storefront booking OrderConfig for a company. Bookings have + * their own steps (confirmed, provider on the way, in progress...) instead of delivery's, + * and a config of their own so the delivery config, which may be customised, is untouched. + */ + public static function getBookingOrderConfig(Company|string|null $company): ?OrderConfig + { + $companyUuid = static::resolveCompanyUuid($company); + if (!$companyUuid) { + return null; + } + + if (isset(static::$bookingConfigCache[$companyUuid])) { + return static::$bookingConfigCache[$companyUuid]; + } + + $config = OrderConfig::where(['company_uuid' => $companyUuid, 'key' => self::BOOKING_CONFIG_KEY, 'namespace' => self::BOOKING_CONFIG_NS])->first() + ?? DB::transaction(fn () => static::createBookingConfig($companyUuid)); + + return static::$bookingConfigCache[$companyUuid] = $config; + } + + /** + * Create (or retrieve) the Storefront booking config for a company. + * + * At the customer's address: requested, confirmed, provider assigned, on the way, arrived, + * in progress, completed. At the store (a pickup booking) the customer comes in, so it goes + * from confirmed straight to in progress: only "provider assigned" is conditional, so it is + * the branch taken when the booking is at the customer's address. + */ + public static function createBookingConfig(Company|string $company): OrderConfig + { + $companyUuid = $company instanceof Company ? $company->uuid : $company; + $atCustomer = [['type' => 'not', 'conditions' => [['field' => 'meta.is_pickup', 'operator' => 'equal', 'value' => 'true']]]]; + + $flow = [ + 'created' => static::bookingActivity('created', 'Booking requested', '{storefront.name} will confirm your booking shortly.', ['accepted', 'canceled']), + 'accepted' => static::bookingActivity('accepted', 'Booking confirmed', '{storefront.name} has confirmed your booking.', ['dispatched', 'in_progress', 'canceled']), + 'dispatched' => static::bookingActivity('dispatched', 'Provider assigned', 'Your provider has been assigned and will set off in time for your appointment.', ['started'], $atCustomer), + 'started' => static::bookingActivity('started', 'Provider on the way', 'Your provider is on the way to you.', ['provider_arrived']), + 'provider_arrived' => static::bookingActivity('provider_arrived', 'Provider arrived', 'Your provider has arrived.', ['in_progress']), + 'in_progress' => static::bookingActivity('in_progress', 'Service in progress', 'Your booking with {storefront.name} is under way.', ['completed']), + 'completed' => static::bookingActivity('completed', 'Booking completed', 'Your booking with {storefront.name} is complete.', [], [], ['complete' => true]), + 'canceled' => static::bookingActivity('canceled', 'Booking canceled', 'Your booking was canceled.', [], [], ['events' => ['order.canceled']]), + ]; + + return OrderConfig::firstOrCreate( + [ + 'company_uuid' => $companyUuid, + 'key' => self::BOOKING_CONFIG_KEY, + 'namespace' => self::BOOKING_CONFIG_NS, + ], + [ + 'name' => 'Storefront Booking', + 'key' => self::BOOKING_CONFIG_KEY, + 'namespace' => self::BOOKING_CONFIG_NS, + 'description' => 'Storefront order configuration for booked services, at the customer\'s address or at the store', + 'core_service' => 1, + 'status' => 'private', + 'version' => '0.0.1', + 'tags' => ['storefront', 'booking', 'services'], + 'entities' => [], + 'meta' => [], + 'flow' => $flow, + ] + ); + } + + /** + * One activity of the booking flow, in the same shape as the delivery flow's. + */ + protected static function bookingActivity(string $code, string $status, string $details, array $activities, array $logic = [], array $overrides = []): array + { + return array_merge([ + 'key' => $code, + 'code' => $code, + 'color' => '#1f2937', + 'logic' => $logic, + 'events' => [], + 'status' => $status, + 'actions' => [], + 'details' => $details, + 'options' => [], + 'complete' => false, + 'entities' => [], + 'sequence' => 0, + 'activities' => $activities, + 'internalId' => (string) Str::uuid(), + 'pod_method' => 'scan', + 'require_pod' => false, + ], $overrides); + } + /** * Normalize a Company|UUID|null to a UUID string (or null if unresolved). */ @@ -592,6 +704,12 @@ protected static function resolveCompanyUuid(Company|string|null $company): ?str public static function createAcceptedActivity(?OrderConfig $orderConfig = null): Activity { + // A config with its own acceptance step (e.g. bookings: "Booking confirmed") uses it. + $flow = $orderConfig ? $orderConfig->flow : null; + if (is_array($flow) && is_array($flow['accepted'] ?? null)) { + return new Activity($flow['accepted'], $orderConfig->activities()->toArray()); + } + return new Activity([ 'key' => 'accepted', 'code' => 'accepted', @@ -633,9 +751,11 @@ public static function autoAcceptOrder(Order $order) return response()->error('Unable to accept order.'); } - // Notify customer order was accepted + // Notify customer order was accepted (a booking's confirmation is sent as its activity) try { - $order->customer->notify(new StorefrontOrderAccepted($order)); + if (!static::isBookingOrder($order)) { + $order->customer->notify(new StorefrontOrderAccepted($order)); + } } catch (\Throwable $e) { Log::error('[Storefront] was unable to notify the customer that their order was accepted.', ['order' => $order->public_id, 'error' => $e->getMessage()]); } @@ -648,6 +768,11 @@ public static function autoDispatchOrder(Order $order, bool $adhoc = true) // Patch order config Storefront::patchOrderConfig($order); + // An at-store booking has nobody to dispatch: it starts when the customer comes in. + if (static::isBookingOrder($order) && $order->isMeta('is_pickup')) { + return $order; + } + if ($order->isMeta('is_pickup')) { $order->updateStatus('pickup_ready'); diff --git a/server/src/Support/StorefrontSocket.php b/server/src/Support/StorefrontSocket.php new file mode 100644 index 00000000..288b9c74 --- /dev/null +++ b/server/src/Support/StorefrontSocket.php @@ -0,0 +1,182 @@ + 'customer', + 'sub' => $customer->uuid, + 'cid' => $storefront->company_uuid, + 'cpid' => static::companyPublicId($storefront->company_uuid), + 'env' => static::ENV, + // The user uuid lets the customer join chats they take part in, such as their + // order chat with the driver: core authorizes chat channels by participant user. + 'ids' => array_values(array_filter([$customer->uuid, $customer->public_id, $customer->user_uuid])), + 'adm' => false, + 'scp' => null, + 'sid' => $storefront->uuid, + ]); + } + + /** + * Builds the `checkout` principal: it may only ever subscribe to its own checkout channel. + */ + public static function checkoutPrincipal(Checkout $checkout): SocketPrincipal + { + return SocketPrincipal::fromClaims([ + 'kind' => 'checkout', + 'sub' => $checkout->uuid, + 'cid' => $checkout->company_uuid, + 'cpid' => static::companyPublicId($checkout->company_uuid), + 'env' => static::ENV, + 'ids' => [$checkout->uuid, $checkout->public_id], + 'adm' => false, + 'scp' => [static::checkoutChannel($checkout)], + 'sid' => $checkout->store_uuid ?? $checkout->network_uuid, + ]); + } + + /** + * Mints the socket token returned with an initialized checkout, or null while socket auth is disabled. + */ + public static function checkoutToken(Checkout $checkout): ?array + { + if (!SocketToken::enabled()) { + return null; + } + + return SocketToken::issue(static::checkoutPrincipal($checkout)); + } + + /** + * The channel checkout progress is published on. + */ + public static function checkoutChannel(Checkout $checkout): string + { + return 'checkout.' . $checkout->public_id; + } + + /** + * Registers storefront's channel resolvers with core-api's socket channel registry. + * + * @param \Fleetbase\Support\SocketCluster\SocketChannelRegistry $registry + */ + public static function registerChannels($registry): void + { + $registry->register('storefront', \Closure::fromCallable([static::class, 'authorizeStorefront'])); + $registry->register('checkout', \Closure::fromCallable([static::class, 'authorizeCheckout'])); + } + + /** + * `storefront.{id}` — id is a store or network uuid, public id or key. + * + * Console users and API credentials may subscribe to their own company's + * storefronts; a customer only to the storefront their token was minted for. + */ + public static function authorizeStorefront(SocketPrincipal $principal, string $id, string $channel): bool + { + $storefront = static::findStorefront($id); + + if (!$storefront || !$storefront->company_uuid || $storefront->company_uuid !== $principal->cid) { + return false; + } + + if ($principal->isCompanyScoped()) { + return true; + } + + if ($principal->kind === 'customer') { + return $principal->sid !== null && $storefront->uuid === $principal->sid; + } + + return false; + } + + /** + * `checkout.{id}` — id is a checkout uuid or public id. + * + * Console users and API credentials may subscribe to their own company's + * checkouts; a customer only to checkouts they own. A `checkout` principal never + * reaches here: its `scp` already limits it to its own channel. + */ + public static function authorizeCheckout(SocketPrincipal $principal, string $id, string $channel): bool + { + $checkout = Checkout::select(['uuid', 'public_id', 'company_uuid', 'owner_uuid']) + ->where(function ($query) use ($id) { + $query->where('uuid', $id)->orWhere('public_id', $id); + }) + ->first(); + + if (!$checkout || !$checkout->company_uuid || $checkout->company_uuid !== $principal->cid) { + return false; + } + + if ($principal->isCompanyScoped()) { + return true; + } + + if ($principal->kind === 'customer') { + return $checkout->owner_uuid !== null && $checkout->owner_uuid === $principal->sub; + } + + return false; + } + + /** + * Finds a store, then a network, by uuid, public id or key. + */ + protected static function findStorefront(string $id): Store|Network|null + { + foreach ([Store::class, Network::class] as $model) { + $storefront = $model::select(['uuid', 'company_uuid']) + ->where(function ($query) use ($id) { + $query->where('uuid', $id)->orWhere('public_id', $id)->orWhere('key', $id); + }) + ->first(); + + if ($storefront) { + return $storefront; + } + } + + return null; + } + + protected static function companyPublicId(?string $companyUuid): ?string + { + if (!$companyUuid) { + return null; + } + + return Company::where('uuid', $companyUuid)->value('public_id'); + } +} diff --git a/server/src/routes.php b/server/src/routes.php index 7099a53f..0f39a4d6 100644 --- a/server/src/routes.php +++ b/server/src/routes.php @@ -88,15 +88,22 @@ function ($router) { $router->group(['prefix' => 'reviews'], function () use ($router) { $router->get('/', 'ReviewController@query'); $router->get('count', 'ReviewController@count'); + $router->get('eligibility', 'ReviewController@eligibility'); $router->get('{id}', 'ReviewController@find'); $router->post('/', 'ReviewController@create'); - $router->delete('{id}', 'ReviewController@find'); + $router->delete('{id}', 'ReviewController@delete'); }); // storefront/v1/orders $router->group(['prefix' => 'orders'], function () use ($router) { $router->put('picked-up', 'OrderController@completeOrderPickup'); $router->post('receipt', 'OrderController@getReceipt'); + $router->get('{id}/activity-flow', 'OrderController@getActivityFlow'); + $router->get('{id}/stores', 'OrderController@getStores'); + $router->get('{id}/chat', 'OrderChatController@show'); + $router->get('{id}/chat/messages', 'OrderChatController@messages'); + $router->post('{id}/chat/messages', 'OrderChatController@send'); + $router->post('{id}/chat/read', 'OrderChatController@read'); }); // storefront/v1/promotions @@ -104,7 +111,7 @@ function ($router) { $router->get('/', 'PromotionController@query'); $router->get('{id}', 'PromotionController@find'); }); - + // storefront/v1/notifications $router->group(['prefix' => 'notifications'], function () use ($router) { $router->get('/', 'NotificationController@query'); @@ -135,6 +142,7 @@ function ($router) { $router->get('/', 'CustomerController@query'); $router->post('register-device', 'CustomerController@registerDevice'); $router->post('unregister-device', 'CustomerController@unregisterDevice'); + $router->post('socket-token', 'CustomerController@socketToken'); $router->get('places', 'CustomerController@places'); $router->get('orders', 'CustomerController@orders'); $router->get('{id}', 'CustomerController@find'); @@ -216,6 +224,8 @@ function ($router, $controller) { $router->post('completed', $controller('markOrderAsCompleted')); $router->patch('cancel', $controller('rejectOrder')); $router->post('unassign-driver', $controller('unassignDriver')); + // Moves an order on a custom flow (e.g. a booking) to its next activity. + $router->patch('update-activity/{id}', $controller('updateActivity')); } ); $router->fleetbaseRoutes( diff --git a/server/tests/Unit/Console/CommandContractsTest.php b/server/tests/Unit/Console/CommandContractsTest.php index 5eba62cc..35c165f1 100644 --- a/server/tests/Unit/Console/CommandContractsTest.php +++ b/server/tests/Unit/Console/CommandContractsTest.php @@ -108,19 +108,31 @@ public function updateMeta($key, $value = null): Fleetbase\FleetOps\Models\Order } } -test('purge carts deletes only expired records and reports the affected count', function () { +test('purge carts deletes only expired open empty carts and keeps every cart that held something', function () { $connection = Model::getConnectionResolver()->connection('mysql'); $schema = $connection->getSchemaBuilder(); $schema->dropIfExists('carts'); $schema->create('carts', function ($table) { $table->increments('id'); + $table->string('name'); + $table->string('checkout_uuid')->nullable(); + $table->string('status')->nullable(); + $table->text('items')->nullable(); $table->timestamp('expires_at'); }); + $expired = now()->subDay(); $connection->table('carts')->insert([ - ['expires_at' => now()->subMinute()], - ['expires_at' => now()->subDay()], - ['expires_at' => now()->addHour()], + // deleted: expired, open and empty (every way an empty cart is stored) + ['name' => 'empty-array', 'checkout_uuid' => null, 'status' => 'open', 'items' => '[]', 'expires_at' => $expired], + ['name' => 'empty-null', 'checkout_uuid' => null, 'status' => null, 'items' => null, 'expires_at' => now()->subMinute()], + ['name' => 'empty-object', 'checkout_uuid' => null, 'status' => 'open', 'items' => '{}', 'expires_at' => $expired], + // kept: abandoned with items, checked out, cleared, linked to a checkout, not expired + ['name' => 'abandoned', 'checkout_uuid' => null, 'status' => 'open', 'items' => '[{"id":"cart_item_1"}]', 'expires_at' => $expired], + ['name' => 'checked-out', 'checkout_uuid' => 'checkout_uuid', 'status' => 'checked_out', 'items' => '[{"id":"cart_item_2"}]', 'expires_at' => $expired], + ['name' => 'checkout-linked-empty', 'checkout_uuid' => 'checkout_uuid', 'status' => null, 'items' => '[]', 'expires_at' => $expired], + ['name' => 'cleared', 'checkout_uuid' => null, 'status' => 'cleared', 'items' => '[{"id":"cart_item_3"}]', 'expires_at' => $expired], + ['name' => 'current', 'checkout_uuid' => null, 'status' => 'open', 'items' => '[]', 'expires_at' => now()->addHour()], ]); $buffer = new BufferedOutput(); @@ -129,8 +141,8 @@ public function updateMeta($key, $value = null): Fleetbase\FleetOps\Models\Order $command->setOutput($output); expect($command->handle())->toBe(Command::SUCCESS) - ->and($connection->table('carts')->count())->toBe(1) - ->and($buffer->fetch())->toContain('Successfully deleted 2 expired carts.'); + ->and($connection->table('carts')->orderBy('id')->pluck('name')->all())->toBe(['abandoned', 'checked-out', 'checkout-linked-empty', 'cleared', 'current']) + ->and($buffer->fetch())->toContain('Successfully deleted 3 expired carts.'); }); test('purge carts restores foreign key enforcement when deletion fails', function () { diff --git a/server/tests/Unit/Http/Controllers/AnalyticsControllerContractsTest.php b/server/tests/Unit/Http/Controllers/AnalyticsControllerContractsTest.php index eb30761f..efb1d2db 100644 --- a/server/tests/Unit/Http/Controllers/AnalyticsControllerContractsTest.php +++ b/server/tests/Unit/Http/Controllers/AnalyticsControllerContractsTest.php @@ -50,6 +50,7 @@ function createAnalyticsControllerSchema(): void }); $schema->create('carts', function ($table) { $table->increments('id'); + $table->string('status')->nullable(); $table->string('uuid')->nullable(); $table->string('company_uuid')->nullable(); $table->text('items')->nullable(); diff --git a/server/tests/Unit/Http/Controllers/CheckoutBoundaryContractsTest.php b/server/tests/Unit/Http/Controllers/CheckoutBoundaryContractsTest.php index 9113ec35..6c75152f 100644 --- a/server/tests/Unit/Http/Controllers/CheckoutBoundaryContractsTest.php +++ b/server/tests/Unit/Http/Controllers/CheckoutBoundaryContractsTest.php @@ -19,6 +19,7 @@ class CheckoutQPayStub extends Fleetbase\Storefront\Support\QPay public static ?Throwable $failure = null; public static bool $sandboxUsed = false; public static bool $authenticated = false; + public static int $checks = 0; public static ?string $invoiceKind = null; public static array $invoiceArguments = []; @@ -42,6 +43,7 @@ public function setAuthToken(?string $accessToken = null): Fleetbase\Storefront\ public function paymentCheck(string $invoiceId, $options = []) { + static::$checks++; if (static::$failure) { throw static::$failure; } @@ -468,12 +470,40 @@ protected function createOrderFromCheckout($checkout, $transactionDetails, $note } } +class CheckoutChannelPayloadStub extends TestableCheckoutController +{ + public static bool $failSerialization = false; + + protected static function checkoutChannelOrder(Fleetbase\FleetOps\Models\Order $order): array + { + if (static::$failSerialization) { + throw new RuntimeException('Order serialization failed'); + } + + return ['id' => $order->public_id]; + } + + public static function initializedCheckoutResponse(Checkout $checkout, array $data) + { + return static::checkoutResponse($checkout, $data); + } +} + +function enableCheckoutSocketAuth(bool $enabled = true): void +{ + config(['broadcasting.connections.socketcluster.auth_key' => $enabled ? 'checkout-socket-test-key-0123456789abcdef' : null]); +} + +afterEach(function () { + enableCheckoutSocketAuth(false); +}); + function createCheckoutBoundarySchema(): void { $connection = Model::getConnectionResolver()->connection('mysql'); $schema = $connection->getSchemaBuilder(); - foreach (['carts', 'gateways', 'contacts', 'service_quotes', 'integrated_vendors', 'checkouts', 'networks', 'stores', 'orders'] as $table) { + foreach (['carts', 'gateways', 'contacts', 'service_quotes', 'integrated_vendors', 'checkouts', 'promotion_redemptions', 'networks', 'stores', 'orders'] as $table) { $schema->dropIfExists($table); } @@ -485,6 +515,7 @@ function createCheckoutBoundarySchema(): void $table->string('company_uuid')->nullable(); $table->string('user_uuid')->nullable(); $table->string('checkout_uuid')->nullable(); + $table->string('status')->nullable(); $table->string('customer_id')->nullable(); $table->string('unique_identifier')->nullable(); $table->string('currency')->nullable(); @@ -563,6 +594,14 @@ function createCheckoutBoundarySchema(): void $table->timestamps(); $table->timestamp('deleted_at')->nullable(); }); + $schema->create('promotion_redemptions', function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('promotion_uuid')->nullable(); + $table->string('checkout_uuid')->nullable(); + $table->string('status')->default('reserved'); + $table->timestamps(); + }); $schema->create('stores', function ($table) { $table->increments('id'); $table->string('uuid')->nullable(); @@ -1307,7 +1346,7 @@ public function notify($notification): void ]); }); -test('checkout status reports qpay pending paid fallback and provider failure states', function () { +test('checkout status answers qpay from the checkout and asks qpay only to verify a return from paying', function () { createCheckoutCaptureExecutionSchema(); $connection = Model::getConnectionResolver()->connection('mysql'); $connection->table('gateways')->insert([ @@ -1322,73 +1361,106 @@ public function notify($notification): void 'password' => 'secret', ]), ]); - $connection->table('checkouts')->insert([ - 'uuid' => 'checkout_uuid', - 'public_id' => 'checkout_abcdefgh', - 'gateway_uuid' => 'qpay_gateway_uuid', - 'options' => json_encode(['qpay_invoice_id' => 'invoice_checkout']), - 'token' => 'checkout-token', - 'captured' => false, - ]); + foreach (['checkout_abcdefgh' => 'checkout_uuid', 'checkout_failing' => 'checkout_failing_uuid', 'checkout_unpaid' => 'checkout_unpaid_uuid'] as $publicId => $uuid) { + $connection->table('checkouts')->insert([ + 'uuid' => $uuid, + 'public_id' => $publicId, + 'gateway_uuid' => 'qpay_gateway_uuid', + 'options' => json_encode(['qpay_invoice_id' => 'invoice_' . $publicId]), + 'token' => 'checkout-token', + 'captured' => false, + ]); + } $connection->table('orders')->insert([ 'uuid' => 'status_order_uuid', 'public_id' => 'order_status', ]); CheckoutQPayStub::$failure = null; - CheckoutQPayStub::$paymentCheckResult = (object) ['rows' => []]; + CheckoutQPayStub::$checks = 0; CheckoutQPayStub::$sandboxUsed = false; + CheckoutQPayStub::$paymentCheckResult = (object) [ + 'count' => 1, + 'paid_amount' => 2500, + 'rows' => [ + (object) [ + 'payment_id' => 'payment_checkout', + 'payment_status' => 'PAID', + 'payment_amount' => 2500, + 'payment_currency' => 'MNT', + 'payment_date' => '2026-07-27 10:00:00', + 'payment_wallet' => 'Most money', + ], + ], + ]; TestableCheckoutController::$statusFallbackOrder = null; TestableCheckoutController::$statusFallbackFailure = null; $controller = new TestableCheckoutController(); - $request = fn () => Request::create('/checkouts/status', 'GET', [ - 'checkout' => 'checkout_abcdefgh', + $request = fn (string $checkout = 'checkout_abcdefgh', bool $verify = false) => Request::create('/checkouts/status', 'GET', [ + 'checkout' => $checkout, 'token' => 'checkout-token', + 'verify' => $verify ? '1' : '0', ]); + $record = fn () => Fleetbase\Storefront\Models\Checkout::where('uuid', 'checkout_uuid')->first()->getOption('qpay_payment'); - $pending = $controller->getCheckoutStatus($request()); - CheckoutQPayStub::$paymentCheckResult = (object) [ - 'rows' => [ - (object) [ - 'payment_id' => 'payment_checkout', - 'payment_status' => 'PAID', - 'payment_amount' => 2500, - 'payment_date' => '2026-07-27 10:00:00', - 'payment_wallet' => 'QPay', - ], - ], - ]; + // Without verify the answer comes from the checkout alone: QPay is not asked. + $pending = $controller->getCheckoutStatus($request())->getData(true); + $checksAfterPending = CheckoutQPayStub::$checks; + + // Back from paying: one verify asks QPay and records the payment. + $paid = $controller->getCheckoutStatus($request('checkout_abcdefgh', true))->getData(true); + $checksAfterVerify = CheckoutQPayStub::$checks; + $recorded = $record(); + + // Recorded payments are answered from the checkout, even when verify is asked again. + $again = $controller->getCheckoutStatus($request('checkout_abcdefgh', true))->getData(true); + $checksAfterAgain = CheckoutQPayStub::$checks; + + // A payment recorded a while ago without an order gets its order (safety net). + $checkout = Fleetbase\Storefront\Models\Checkout::where('uuid', 'checkout_uuid')->first(); + $checkout->updateOption('qpay_payment', array_merge((array) $recorded, ['recorded_at' => now()->subSeconds(30)->toIso8601String()])); + TestableCheckoutController::$statusFallbackOrder = Fleetbase\FleetOps\Models\Order::where('uuid', 'status_order_uuid')->firstOrFail(); session(['storefront_key' => null]); - TestableCheckoutController::$statusFallbackOrder = Fleetbase\FleetOps\Models\Order::where( - 'uuid', - 'status_order_uuid' - )->firstOrFail(); - $paid = $controller->getCheckoutStatus($request()); + $fallback = $controller->getCheckoutStatus($request())->getData(true); + + // A concurrent capture that won the race is picked up. TestableCheckoutController::$statusFallbackFailure = new RuntimeException('Concurrent status capture'); - $raceRecovered = $controller->getCheckoutStatus($request()); + $raceRecovered = $controller->getCheckoutStatus($request())->getData(true); TestableCheckoutController::$statusFallbackFailure = null; - $alreadyCompleted = $controller->getCheckoutStatus($request()); - CheckoutQPayStub::$failure = new RuntimeException('QPay status unavailable'); - $failure = $controller->getCheckoutStatus($request()); - CheckoutQPayStub::$failure = null; - $pendingData = $pending->getData(true); - $paidData = $paid->getData(true); - expect($pendingData['status'])->toBe('pending') - ->and($pendingData['payment'])->toBeNull() - ->and($pendingData['order'])->toBeNull() - ->and($paidData['status'])->toBe('completed') - ->and($paidData['payment']['payment_id'])->toBe('payment_checkout') - ->and($paidData['payment']['payment_status'])->toBe('PAID') - ->and($paidData['payment']['payment_amount'])->toBe(2500) - ->and($paidData['payment']['payment_wallet'])->toBe('QPay') - ->and($paidData['order']['id'])->toBe('order_status') - ->and($raceRecovered->getData(true)['status'])->toBe('completed') - ->and($alreadyCompleted->getData(true)['status'])->toBe('completed') + + // QPay asked to verify but failing surfaces as an error; an unpaid verify is throttled. + CheckoutQPayStub::$failure = new RuntimeException('QPay status unavailable'); + $failure = $controller->getCheckoutStatus($request('checkout_failing', true)); + CheckoutQPayStub::$failure = null; + CheckoutQPayStub::$paymentCheckResult = (object) ['count' => 0, 'rows' => []]; + $unpaidFirst = $controller->getCheckoutStatus($request('checkout_unpaid', true))->getData(true); + $checksUnpaid = CheckoutQPayStub::$checks; + $unpaidSecond = $controller->getCheckoutStatus($request('checkout_unpaid', true))->getData(true); + + expect($pending['status'])->toBe('pending') + ->and($pending['payment'])->toBeNull() + ->and($checksAfterPending)->toBe(0) + ->and($paid['status'])->toBe('paid') + ->and($paid['order'])->toBeNull() + ->and($paid['payment']['payment_id'])->toBe('payment_checkout') + ->and($paid['payment']['payment_amount'])->toBe(2500) + ->and($paid['payment']['payment_wallet'])->toBe('Most money') + ->and($checksAfterVerify)->toBe(1) ->and(CheckoutQPayStub::$sandboxUsed)->toBeTrue() + ->and(data_get($recorded, 'payment_currency'))->toBe('MNT') + ->and(data_get($recorded, 'recorded_at'))->not->toBeNull() + ->and($again['status'])->toBe('paid') + ->and($checksAfterAgain)->toBe(1) + ->and($fallback['status'])->toBe('completed') + ->and($fallback['order']['id'])->toBe('order_status') + ->and($raceRecovered['status'])->toBe('completed') ->and($failure->getStatusCode())->toBe(500) ->and($failure->getData(true))->toBe([ 'error' => 'Failed to retrieve checkout status', 'message' => 'QPay status unavailable', - ]); + ]) + ->and($unpaidFirst['status'])->toBe('pending') + ->and($unpaidSecond['status'])->toBe('pending') + ->and(CheckoutQPayStub::$checks)->toBe($checksUnpaid); }); test('customer lookup safely returns null for unknown customer aliases', function () { @@ -2454,7 +2526,35 @@ public function request($method, $absUrl, $headers, $params, $hasFile, $apiMode 'PUT', $input )); + $checkout = Checkout::query()->first(); + + // Updating the same PaymentIntent again (another payment method chosen) updates that + // checkout instead of adding one capture couldn't verify. + $again = $controller->updateStripePaymentIntent(Request::create( + '/checkout/stripe-update', + 'PUT', + [...$input, 'pickup' => false] + )); + $againData = $again->getData(true); + + // Another customer's checkout keeps its PaymentIntent. + $connection->table('checkouts')->where('uuid', $checkout->uuid)->update(['owner_uuid' => 'other_customer_uuid']); + $foreign = $controller->updateStripePaymentIntent(Request::create( + '/checkout/stripe-update', + 'PUT', + $input + )); + $connection->table('checkouts')->where('uuid', $checkout->uuid)->update(['owner_uuid' => 'customer_uuid', 'is_pickup' => true]); Stripe\ApiRequestor::setHttpClient(new Stripe\HttpClient\CurlClient()); + + expect(Checkout::query()->count())->toBe(1) + ->and($againData['checkout'])->toBe($checkout->public_id) + ->and($againData['token'])->toBe($checkout->token) + ->and($checkout->stripe_payment_intent_id)->toBe('pi_checkout') + ->and(Checkout::query()->first()->stripe_payment_intent_id)->toBe('pi_checkout') + ->and($foreign->getStatusCode())->toBe(422) + ->and($foreign->getData(true))->toBe(['error' => 'PaymentIntent belongs to another checkout.']); + $checkout = Checkout::query()->first(); $meta = json_decode($connection->table('contacts')->where('uuid', 'customer_uuid')->value('meta'), true); $updatedData = $updated->getData(true); @@ -2794,7 +2894,8 @@ public function request($method, $absUrl, $headers, $params, $hasFile, $apiMode CheckoutQPayStub::$sandboxUsed = false; CheckoutQPayStub::$authenticated = false; Fleetbase\Support\SocketCluster\SocketClusterService::$published = []; - $controller = new TestableCheckoutController(); + CheckoutChannelPayloadStub::$failSerialization = false; + $controller = new CheckoutChannelPayloadStub(); $missingInvoice = $controller->captureQPayCallback(Request::create('/checkout/qpay', 'POST', [ 'checkout' => 'checkout_abcdefgh', @@ -2857,7 +2958,243 @@ public function request($method, $absUrl, $headers, $params, $hasFile, $apiMode ->and($sandboxError->getData(true)['error']['error'])->toBe('PAYMENT_NOT_PAID') ->and(CheckoutQPayStub::$sandboxUsed)->toBeTrue() ->and(CheckoutQPayStub::$authenticated)->toBeTrue() - ->and(Fleetbase\Support\SocketCluster\SocketClusterService::$published)->toHaveCount(3); + ->and(Fleetbase\Support\SocketCluster\SocketClusterService::$published)->toBe([ + // The realtime payload carries what a storefront client acts on — never the raw payment row. + ['checkout.checkout_abcdefgh', [ + 'checkout' => 'checkout_abcdefgh', + 'status' => 'completed', + 'order' => ['id' => 'order_abcdefgh'], + 'error' => null, + ]], + ['checkout.checkout_abcdefgh', [ + 'checkout' => 'checkout_abcdefgh', + 'status' => 'completed', + 'order' => ['id' => 'order_abcdefgh'], + 'error' => null, + ]], + ['checkout.checkout_abcdefgh', [ + 'checkout' => 'checkout_abcdefgh', + 'status' => 'failed', + 'order' => null, + 'error' => [ + 'error' => 'PAYMENT_NOT_PAID', + 'message' => 'Payment has not been paid!', + ], + ]], + ]); +}); + +test('qpay callback publishes paid before an order exists and survives a failed publish', function () { + createCheckoutBoundarySchema(); + $connection = Model::getConnectionResolver()->connection('mysql'); + $connection->table('gateways')->insert([ + 'uuid' => 'qpay_gateway_uuid', + 'code' => 'qpay', + 'owner_uuid' => 'store_uuid', + 'type' => 'qpay', + 'sandbox' => true, + 'callback_url' => 'https://storefront.test/qpay', + 'config' => json_encode(['username' => 'merchant', 'password' => 'secret']), + ]); + $connection->table('orders')->insert([ + 'uuid' => 'order_uuid', + 'public_id' => 'order_abcdefgh', + ]); + $connection->table('checkouts')->insert([ + [ + 'uuid' => 'checkout_pending_uuid', + 'public_id' => 'checkout_pending', + 'gateway_uuid' => 'qpay_gateway_uuid', + 'order_uuid' => null, + 'options' => '{}', + 'token' => 'checkout-token-pending', + ], + [ + 'uuid' => 'checkout_ordered_uuid', + 'public_id' => 'checkout_ordered', + 'gateway_uuid' => 'qpay_gateway_uuid', + 'order_uuid' => 'order_uuid', + 'options' => '{}', + 'token' => 'checkout-token-ordered', + ], + ]); + Fleetbase\Support\SocketCluster\SocketClusterService::$published = []; + CheckoutChannelPayloadStub::$failSerialization = false; + $controller = new CheckoutChannelPayloadStub(); + + $paidWithoutOrder = $controller->captureQPayCallback(Request::create('/checkout/qpay', 'POST', [ + 'checkout' => 'checkout_pending', + 'respond' => true, + 'test' => 'success', + ])); + CheckoutChannelPayloadStub::$failSerialization = true; + $publishFailed = $controller->captureQPayCallback(Request::create('/checkout/qpay', 'POST', [ + 'checkout' => 'checkout_ordered', + 'respond' => true, + 'test' => 'success', + ])); + CheckoutChannelPayloadStub::$failSerialization = false; + + expect($paidWithoutOrder->getData(true)['payment']['payment_status'])->toBe('PAID') + // A failed publish never turns a recorded payment into an error response. + ->and($publishFailed->getStatusCode())->toBe(200) + ->and($publishFailed->getData(true)['payment']['payment_status'])->toBe('PAID') + ->and(Fleetbase\Support\SocketCluster\SocketClusterService::$published)->toBe([ + ['checkout.checkout_pending', [ + 'checkout' => 'checkout_pending', + 'status' => 'paid', + 'order' => null, + 'error' => null, + ]], + ]); +}); + +test('qpay callback publishes the order serialized as checkout status returns it', function () { + createCheckoutCaptureExecutionSchema(); + $connection = Model::getConnectionResolver()->connection('mysql'); + $connection->table('gateways')->insert([ + 'uuid' => 'qpay_gateway_uuid', + 'code' => 'qpay', + 'owner_uuid' => 'store_uuid', + 'type' => 'qpay', + 'sandbox' => true, + 'callback_url' => 'https://storefront.test/qpay', + 'config' => json_encode(['username' => 'merchant', 'password' => 'secret']), + ]); + $connection->table('orders')->insert([ + 'uuid' => 'status_order_uuid', + 'public_id' => 'order_status', + ]); + $connection->table('checkouts')->insert([ + 'uuid' => 'checkout_uuid', + 'public_id' => 'checkout_abcdefgh', + 'gateway_uuid' => 'qpay_gateway_uuid', + 'order_uuid' => 'status_order_uuid', + 'options' => json_encode(['qpay_invoice_id' => 'invoice_checkout']), + 'token' => 'checkout-token', + 'captured' => true, + ]); + session(['storefront_key' => null]); + CheckoutQPayStub::$failure = null; + CheckoutQPayStub::$paymentCheckResult = (object) [ + 'count' => 1, + 'rows' => [ + (object) [ + 'payment_id' => 'payment_checkout', + 'payment_status' => 'PAID', + 'payment_amount' => 2500, + 'payment_wallet' => 'QPay', + ], + ], + ]; + TestableCheckoutController::$statusFallbackOrder = null; + TestableCheckoutController::$statusFallbackFailure = null; + Fleetbase\Support\SocketCluster\SocketClusterService::$published = []; + + (new TestableCheckoutController())->captureQPayCallback(Request::create('/checkout/qpay', 'POST', [ + 'checkout' => 'checkout_abcdefgh', + ])); + $published = Fleetbase\Support\SocketCluster\SocketClusterService::$published; + + expect($published)->toHaveCount(1) + ->and($published[0][0])->toBe('checkout.checkout_abcdefgh') + ->and(array_keys($published[0][1]))->toBe(['checkout', 'status', 'order', 'error']) + ->and($published[0][1]['status'])->toBe('completed') + ->and($published[0][1]['order']['id'])->toBe('order_status') + ->and($published[0][1]['error'])->toBeNull(); +}); + +test('initialized checkouts carry a checkout-scoped socket token only while socket auth is enabled', function () { + createCheckoutBoundarySchema(); + $schema = Model::getConnectionResolver()->connection('mysql')->getSchemaBuilder(); + $schema->dropIfExists('companies'); + $schema->create('companies', function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('public_id')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + Model::getConnectionResolver()->connection('mysql')->table('companies')->insert([ + 'uuid' => 'company_uuid', + 'public_id' => 'company_public', + ]); + session([ + 'company' => 'company_uuid', + 'storefront_store' => 'store_uuid', + 'storefront_network' => null, + ]); + $initialize = function () { + $cart = new Cart(); + $cart->forceFill([ + 'uuid' => 'cart_uuid', + 'currency' => 'USD', + 'items' => [['id' => 'line_one', 'quantity' => 1, 'subtotal' => 1000]], + 'events' => [], + ]); + $customer = new Fleetbase\Storefront\Models\Customer(); + $customer->forceFill(['uuid' => 'customer_uuid']); + $gateway = Gateway::cash(); + $gateway->forceFill(['uuid' => 'gateway_uuid']); + + return CheckoutController::initializeCashCheckout( + $customer, + $gateway, + null, + $cart, + (object) ['is_pickup' => true, 'tip' => false, 'delivery_tip' => false], + Request::create('/checkout') + ); + }; + + $disabled = $initialize(); + enableCheckoutSocketAuth(); + $enabled = $initialize(); + $checkout = Checkout::where('public_id', $enabled->getData(true)['checkout'])->firstOrFail(); + $socket = $enabled->getData(true)['socket_token']; + $claims = Fleetbase\Support\SocketCluster\SocketToken::verify($socket['token']); + + expect(array_keys($disabled->getData(true)))->toBe(['checkout', 'token']) + ->and(array_keys($enabled->getData(true)))->toBe(['checkout', 'token', 'socket_token']) + ->and(array_keys($socket))->toBe(['token', 'expires_in', 'expires_at']) + ->and($claims->kind)->toBe('checkout') + ->and($claims->sub)->toBe($checkout->uuid) + ->and($claims->ids)->toBe([$checkout->uuid, $checkout->public_id]) + ->and($claims->scp)->toBe(['checkout.' . $checkout->public_id]) + ->and($claims->cid)->toBe('company_uuid') + ->and($claims->cpid)->toBe('company_public') + ->and($claims->sid)->toBe('store_uuid') + ->and($claims->env)->toBe('live'); +}); + +test('checkout response helper adds the socket token beside the existing fields', function () { + createCheckoutBoundarySchema(); + $schema = Model::getConnectionResolver()->connection('mysql')->getSchemaBuilder(); + $schema->dropIfExists('companies'); + $schema->create('companies', function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('public_id')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + Model::getConnectionResolver()->connection('mysql')->table('checkouts')->insert([ + 'uuid' => 'checkout_uuid', + 'public_id' => 'checkout_network', + 'company_uuid' => 'company_uuid', + 'network_uuid' => 'network_uuid', + 'token' => 'checkout-token', + ]); + $checkout = Checkout::where('uuid', 'checkout_uuid')->firstOrFail(); + enableCheckoutSocketAuth(); + + $data = CheckoutChannelPayloadStub::initializedCheckoutResponse($checkout, ['invoice' => ['invoice_id' => 'inv'], 'checkout' => 'checkout_network', 'token' => 'checkout-token'])->getData(true); + $claims = Fleetbase\Support\SocketCluster\SocketToken::verify($data['socket_token']['token']); + + expect($data['invoice'])->toBe(['invoice_id' => 'inv']) + ->and($claims->scp)->toBe(['checkout.checkout_network']) + ->and($claims->sid)->toBe('network_uuid') + ->and($claims->cpid)->toBeNull(); }); test('single and multiple order capture reject invalid checkout tokens safely', function () { diff --git a/server/tests/Unit/Http/Controllers/CustomerControllerContractsTest.php b/server/tests/Unit/Http/Controllers/CustomerControllerContractsTest.php index 4fa4ff91..f8f19d7e 100644 --- a/server/tests/Unit/Http/Controllers/CustomerControllerContractsTest.php +++ b/server/tests/Unit/Http/Controllers/CustomerControllerContractsTest.php @@ -452,6 +452,10 @@ public function request($method, $absUrl, $headers, $params, $hasFile, $apiMode }); $failedEphemeralKey = $controller->getStripeEphemeralKey(Request::create('/customer/stripe/key')); $failedSetupIntent = $controller->getStripeSetupIntent(Request::create('/customer/stripe/setup')); + // Creating the Stripe customer fails too (e.g. the gateway's key is refused): an error, not an exception. + $connection->table('contacts')->where('uuid', $customerUuid)->update(['meta' => '{}']); + $failedCreateEphemeralKey = $controller->getStripeEphemeralKey(Request::create('/customer/stripe/key')); + $failedCreateSetupIntent = $controller->getStripeSetupIntent(Request::create('/customer/stripe/setup')); $connection->table('gateways')->delete(); $missingEphemeralGateway = $controller->getStripeEphemeralKey(Request::create('/customer/stripe/key')); $missingSetupGateway = $controller->getStripeSetupIntent(Request::create('/customer/stripe/setup')); @@ -559,6 +563,10 @@ public function captureException(Throwable $exception): void ->and($createdSetupIntent->getData(true)['customerId'])->toBe('cus_created_customer') ->and($failedEphemeralKey->getData(true))->toBe(['error' => 'Stripe customer endpoint unavailable']) ->and($failedSetupIntent->getData(true))->toBe(['error' => 'Stripe customer endpoint unavailable']) + ->and($failedCreateEphemeralKey->getStatusCode())->toBe(400) + ->and($failedCreateEphemeralKey->getData(true))->toBe(['error' => 'Stripe customer endpoint unavailable']) + ->and($failedCreateSetupIntent->getStatusCode())->toBe(400) + ->and($failedCreateSetupIntent->getData(true))->toBe(['error' => 'Stripe customer endpoint unavailable']) ->and($missingEphemeralGateway->getData(true))->toBe(['error' => 'Stripe not setup.']) ->and($missingSetupGateway->getData(true))->toBe(['error' => 'Stripe not setup.']) ->and($closureStart->getData(true))->toBe(['error' => 'Customer user account not found.']) diff --git a/server/tests/Unit/Http/Controllers/NetworkApiControllerContractsTest.php b/server/tests/Unit/Http/Controllers/NetworkApiControllerContractsTest.php index 60591b7d..c147c9c4 100644 --- a/server/tests/Unit/Http/Controllers/NetworkApiControllerContractsTest.php +++ b/server/tests/Unit/Http/Controllers/NetworkApiControllerContractsTest.php @@ -613,3 +613,78 @@ function createNetworkApiControllerSchema(): void ->and($missingLocations->getStatusCode())->toBe(400) ->and($missingTags->getStatusCode())->toBe(400); }); + +test('network stores endpoint ignores distance filters without a usable location', function () { + createNetworkApiControllerSchema(); + $connection = Model::getConnectionResolver()->connection('mysql'); + $connection->table('networks')->insert(['uuid' => 'network_uuid']); + $connection->table('stores')->insert([ + ['uuid' => 'store_one_uuid', 'public_id' => 'store_one', 'company_uuid' => 'company_uuid', 'name' => 'One', 'created_at' => '2026-01-01 00:00:00'], + ['uuid' => 'store_two_uuid', 'public_id' => 'store_two', 'company_uuid' => 'company_uuid', 'name' => 'Two', 'created_at' => '2026-02-01 00:00:00'], + ]); + $connection->table('network_stores')->insert([ + ['network_uuid' => 'network_uuid', 'store_uuid' => 'store_one_uuid'], + ['network_uuid' => 'network_uuid', 'store_uuid' => 'store_two_uuid'], + ]); + $connection->table('store_locations')->insert([ + ['uuid' => 'location_one_uuid', 'store_uuid' => 'store_one_uuid'], + ['uuid' => 'location_two_uuid', 'store_uuid' => 'store_two_uuid'], + ]); + session([ + 'company' => 'company_uuid', + 'storefront_store' => null, + 'storefront_network' => 'network_uuid', + ]); + $controller = new NetworkController(); + + // Without a location there is nothing to measure from, so the distance filter no longer + // removes every store and limit/offset stay in SQL. + $withoutLocation = $controller->stores(Request::create('/network/stores', 'GET', [ + 'sort' => 'oldest', + 'maximum_distance' => 1000, + 'limit' => 1, + 'offset' => 1, + ])); + // The coordinate parser's (0, 0) fallback is treated the same as no location. + $nullIsland = $controller->stores(Request::create('/network/stores', 'GET', [ + 'sort' => 'nearest', + 'location' => 'not-a-location', + ])); + + expect($withoutLocation->resource->pluck('uuid')->all())->toBe(['store_two_uuid']) + ->and($nullIsland->resource->pluck('uuid')->sort()->values()->all())->toBe(['store_one_uuid', 'store_two_uuid']) + ->and($nullIsland->resource->first()->distance)->toBeNull(); +}); + +test('network stores endpoint preloads each store rating in the store query', function () { + createNetworkApiControllerSchema(); + $connection = Model::getConnectionResolver()->connection('mysql'); + $connection->table('networks')->insert(['uuid' => 'network_uuid']); + $connection->table('stores')->insert([ + ['uuid' => 'store_rated_uuid', 'public_id' => 'store_rated', 'company_uuid' => 'company_uuid', 'name' => 'Rated'], + ['uuid' => 'store_new_uuid', 'public_id' => 'store_new', 'company_uuid' => 'company_uuid', 'name' => 'New'], + ]); + $connection->table('network_stores')->insert([ + ['network_uuid' => 'network_uuid', 'store_uuid' => 'store_rated_uuid'], + ['network_uuid' => 'network_uuid', 'store_uuid' => 'store_new_uuid'], + ]); + $connection->table('store_locations')->insert([ + ['uuid' => 'location_rated_uuid', 'store_uuid' => 'store_rated_uuid'], + ['uuid' => 'location_new_uuid', 'store_uuid' => 'store_new_uuid'], + ]); + $connection->table('reviews')->insert([ + ['uuid' => 'review_a', 'subject_uuid' => 'store_rated_uuid', 'rating' => 5], + ['uuid' => 'review_b', 'subject_uuid' => 'store_rated_uuid', 'rating' => 4], + ]); + session([ + 'company' => 'company_uuid', + 'storefront_store' => null, + 'storefront_network' => 'network_uuid', + ]); + + $stores = (new NetworkController())->stores(Request::create('/network/stores', 'GET'))->resource->keyBy('uuid'); + + expect(array_key_exists('reviews_avg_rating', $stores['store_rated_uuid']->getAttributes()))->toBeTrue() + ->and($stores['store_rated_uuid']->rating)->toBe(4.5) + ->and($stores['store_new_uuid']->rating)->toBe(0); +}); diff --git a/server/tests/Unit/Http/Controllers/OrderChatControllerTest.php b/server/tests/Unit/Http/Controllers/OrderChatControllerTest.php new file mode 100644 index 00000000..9b206316 --- /dev/null +++ b/server/tests/Unit/Http/Controllers/OrderChatControllerTest.php @@ -0,0 +1,795 @@ +writes[] = [$path, $contents, $options]; + + return true; + } + + public function url($path) + { + return 'https://cdn.test/' . $path; + } + + public function exists($path) + { + return false; + } + + public function get($path) + { + return null; + } + + public function readStream($path) + { + return null; + } + + public function writeStream($path, $resource, array $options = []) + { + return false; + } + + public function getVisibility($path) + { + return 'private'; + } + + public function setVisibility($path, $visibility) + { + return false; + } + + public function prepend($path, $data) + { + return false; + } + + public function append($path, $data) + { + return false; + } + + public function delete($paths) + { + return false; + } + + public function copy($from, $to) + { + return false; + } + + public function move($from, $to) + { + return false; + } + + public function size($path) + { + return 0; + } + + public function lastModified($path) + { + return 0; + } + + public function files($directory = null, $recursive = false) + { + return []; + } + + public function allFiles($directory = null) + { + return []; + } + + public function directories($directory = null, $recursive = false) + { + return []; + } + + public function allDirectories($directory = null) + { + return []; + } + + public function makeDirectory($path) + { + return false; + } + + public function deleteDirectory($directory) + { + return false; + } +} + +function orderChatDb(): Illuminate\Database\Connection +{ + return Model::getConnectionResolver()->connection('mysql'); +} + +function createOrderChatSchema(): void +{ + $schema = orderChatDb()->getSchemaBuilder(); + foreach (['stores', 'networks', 'contacts', 'personal_access_tokens', 'users', 'drivers', 'orders', 'chat_channels', 'chat_participants', 'chat_messages', 'chat_attachments', 'chat_receipts', 'chat_logs', 'files'] as $table) { + $schema->dropIfExists($table); + } + foreach (['stores', 'networks'] as $storefrontTable) { + $schema->create($storefrontTable, function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('public_id')->nullable(); + $table->string('company_uuid')->nullable(); + $table->string('key')->nullable(); + $table->string('name')->nullable(); + $table->text('options')->nullable(); + $table->text('translations')->nullable(); + $table->text('tags')->nullable(); + $table->timestamp('deleted_at')->nullable(); + }); + } + $schema->create('contacts', function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('public_id')->nullable(); + $table->string('company_uuid')->nullable(); + $table->string('user_uuid')->nullable(); + $table->string('type')->nullable(); + $table->string('name')->nullable(); + $table->text('meta')->nullable(); + $table->timestamps(); + $table->timestamp('deleted_at')->nullable(); + }); + $schema->create('personal_access_tokens', function ($table) { + $table->increments('id'); + $table->string('tokenable_type')->nullable(); + $table->string('tokenable_id')->nullable(); + $table->string('name'); + $table->string('token', 64)->unique(); + $table->text('abilities')->nullable(); + $table->timestamp('last_used_at')->nullable(); + $table->timestamp('expires_at')->nullable(); + $table->timestamps(); + }); + $schema->create('users', function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('public_id')->nullable(); + $table->string('company_uuid')->nullable(); + $table->string('avatar_uuid')->nullable(); + $table->string('name')->nullable(); + $table->string('email')->nullable(); + $table->string('phone')->nullable(); + $table->string('type')->nullable(); + $table->string('status')->nullable(); + $table->timestamp('last_login')->nullable(); + $table->timestamps(); + $table->timestamp('deleted_at')->nullable(); + }); + $schema->create('drivers', function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('public_id')->nullable(); + $table->string('company_uuid')->nullable(); + $table->string('user_uuid')->nullable(); + $table->timestamps(); + $table->timestamp('deleted_at')->nullable(); + }); + $schema->create('orders', function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('public_id')->nullable(); + $table->string('company_uuid')->nullable(); + $table->string('customer_uuid')->nullable(); + $table->string('customer_type')->nullable(); + $table->string('driver_assigned_uuid')->nullable(); + $table->string('status')->nullable(); + $table->text('meta')->nullable(); + $table->timestamps(); + $table->timestamp('deleted_at')->nullable(); + }); + $schema->create('chat_channels', function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('public_id')->nullable(); + $table->string('company_uuid')->nullable(); + $table->string('created_by_uuid')->nullable(); + $table->string('name')->nullable(); + $table->string('slug')->nullable(); + $table->text('meta')->nullable(); + $table->timestamps(); + $table->timestamp('deleted_at')->nullable(); + }); + $schema->create('chat_participants', function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('public_id')->nullable(); + $table->string('company_uuid')->nullable(); + $table->string('chat_channel_uuid')->nullable(); + $table->string('user_uuid')->nullable(); + $table->timestamps(); + $table->timestamp('deleted_at')->nullable(); + }); + $schema->create('chat_messages', function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('public_id')->nullable(); + $table->string('company_uuid')->nullable(); + $table->string('chat_channel_uuid')->nullable(); + $table->string('sender_uuid')->nullable(); + $table->text('content')->nullable(); + $table->timestamps(); + $table->timestamp('deleted_at')->nullable(); + }); + $schema->create('chat_attachments', function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('public_id')->nullable(); + $table->string('company_uuid')->nullable(); + $table->string('chat_channel_uuid')->nullable(); + $table->string('chat_message_uuid')->nullable(); + $table->string('sender_uuid')->nullable(); + $table->string('file_uuid')->nullable(); + $table->timestamps(); + $table->timestamp('deleted_at')->nullable(); + }); + $schema->create('chat_receipts', function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('public_id')->nullable(); + $table->string('company_uuid')->nullable(); + $table->string('chat_message_uuid')->nullable(); + $table->string('participant_uuid')->nullable(); + $table->timestamp('read_at')->nullable(); + $table->timestamps(); + $table->timestamp('deleted_at')->nullable(); + }); + $schema->create('chat_logs', function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('public_id')->nullable(); + $table->string('company_uuid')->nullable(); + $table->string('chat_channel_uuid')->nullable(); + $table->string('initiator_uuid')->nullable(); + $table->string('event_type')->nullable(); + $table->text('content')->nullable(); + $table->text('subjects')->nullable(); + $table->text('meta')->nullable(); + $table->string('status')->nullable(); + $table->timestamps(); + $table->timestamp('deleted_at')->nullable(); + }); + $schema->create('files', function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('public_id')->nullable(); + $table->string('company_uuid')->nullable(); + $table->string('uploader_uuid')->nullable(); + $table->string('subject_uuid')->nullable(); + $table->string('subject_type')->nullable(); + $table->string('name')->nullable(); + $table->string('original_filename')->nullable(); + $table->string('slug')->nullable(); + $table->string('disk')->nullable(); + $table->string('extension')->nullable(); + $table->string('content_type')->nullable(); + $table->string('path')->nullable(); + $table->string('bucket')->nullable(); + $table->string('type')->nullable(); + $table->integer('file_size')->nullable(); + $table->timestamps(); + $table->timestamp('deleted_at')->nullable(); + }); + + $db = orderChatDb(); + $db->table('stores')->insert(['uuid' => 'store_uuid', 'public_id' => 'store_bloom', 'company_uuid' => 'company_uuid', 'key' => 'store_key', 'name' => 'Bloom']); + $db->table('networks')->insert(['uuid' => 'network_uuid', 'public_id' => 'network_market', 'company_uuid' => 'company_uuid', 'key' => 'network_key', 'name' => 'Market']); + $db->table('users')->insert([ + ['uuid' => 'customer_user_uuid', 'public_id' => 'user_customer', 'company_uuid' => 'company_uuid', 'name' => 'Mei Tan'], + ['uuid' => 'driver_user_uuid', 'public_id' => 'user_driver', 'company_uuid' => 'company_uuid', 'name' => 'Ravi K.'], + ['uuid' => 'second_driver_user_uuid', 'public_id' => 'user_driver_2', 'company_uuid' => 'company_uuid', 'name' => 'Arun S.'], + ]); + $db->table('contacts')->insert([ + ['uuid' => CHAT_CUSTOMER_UUID, 'public_id' => 'contact_mei', 'company_uuid' => 'company_uuid', 'user_uuid' => 'customer_user_uuid', 'type' => 'customer', 'name' => 'Mei Tan'], + ['uuid' => 'other_customer_uuid', 'public_id' => 'contact_other', 'company_uuid' => 'company_uuid', 'user_uuid' => 'other_user_uuid', 'type' => 'customer', 'name' => 'Other'], + ]); + $db->table('drivers')->insert([ + ['uuid' => 'driver_uuid', 'public_id' => 'driver_ravi', 'company_uuid' => 'company_uuid', 'user_uuid' => 'driver_user_uuid'], + ['uuid' => 'second_driver_uuid', 'public_id' => 'driver_arun', 'company_uuid' => 'company_uuid', 'user_uuid' => 'second_driver_user_uuid'], + ]); + $order = fn (string $publicId, array $overrides = []) => array_merge([ + 'uuid' => $publicId . '_uuid', + 'public_id' => $publicId, + 'company_uuid' => 'company_uuid', + 'customer_uuid' => CHAT_CUSTOMER_UUID, + 'customer_type' => Contact::class, + 'driver_assigned_uuid' => 'driver_uuid', + 'status' => 'dispatched', + 'meta' => json_encode(['storefront_id' => 'store_bloom']), + ], $overrides); + $db->table('orders')->insert([ + $order('order_active'), + $order('order_waiting', ['driver_assigned_uuid' => null]), + $order('order_done', ['status' => 'completed']), + $order('order_done_never_chatted', ['status' => 'completed']), + $order('order_other_customer', ['customer_uuid' => 'other_customer_uuid']), + $order('order_other_store', ['meta' => json_encode(['storefront_id' => 'store_elsewhere'])]), + $order('order_network', ['meta' => json_encode(['storefront_id' => 'store_bloom', 'storefront_network_id' => 'network_market'])]), + ]); + $db->table('personal_access_tokens')->insert([ + 'name' => CHAT_CUSTOMER_UUID, + 'token' => hash('sha256', 'chat-secret'), + 'abilities' => '["*"]', + 'created_at' => now(), + 'updated_at' => now(), + ]); +} + +function bindOrderChatRequest(?string $token = 'chat-secret', string $storefrontKey = 'store_key', array $input = [], string $method = 'GET', string $requestClass = Request::class): Request +{ + $request = $requestClass::create('/orders/chat', $method, $input); + if ($token) { + $request->headers->set('Customer-Token', $token); + } + $request->setLaravelSession(new Illuminate\Session\Store('order-chat', new Illuminate\Session\ArraySessionHandler(120))); + app()->instance('request', $request); + session(['company' => 'company_uuid', 'storefront_key' => $storefrontKey, 'storefront_store' => null, 'storefront_network' => null]); + + return $request; +} + +function orderChatData($response): array +{ + return $response->resolve(request()); +} + +function driverSays(ChatChannel $channel, string $content, string $at, string $driverUserUuid = 'driver_user_uuid'): ChatMessage +{ + $sender = ChatParticipant::where('chat_channel_uuid', $channel->uuid)->where('user_uuid', $driverUserUuid)->first(); + + $message = ChatMessage::create(['company_uuid' => 'company_uuid', 'chat_channel_uuid' => $channel->uuid, 'sender_uuid' => $sender->uuid, 'content' => $content]); + orderChatDb()->table('chat_messages')->where('uuid', $message->uuid)->update(['created_at' => $at]); + + return $message->fresh(); +} + +/** + * Records notifications instead of sending them. + */ +function fakeChatNotifications(): object +{ + $dispatcher = new class implements Illuminate\Contracts\Notifications\Dispatcher { + public array $sent = []; + + public function send($notifiables, $notification) + { + $this->sendNow($notifiables, $notification); + } + + public function sendNow($notifiables, $notification, ?array $channels = null) + { + foreach (is_iterable($notifiables) && !$notifiables instanceof Model ? $notifiables : [$notifiables] as $notifiable) { + $this->sent[] = [$notifiable, $notification]; + } + } + + public function sentTo(string $uuid, string $class): array + { + return array_values(array_map(fn ($pair) => $pair[1], array_filter($this->sent, fn ($pair) => data_get($pair[0], 'uuid') === $uuid && $pair[1] instanceof $class))); + } + }; + app()->instance(Illuminate\Contracts\Notifications\Dispatcher::class, $dispatcher); + Illuminate\Support\Facades\Notification::clearResolvedInstance(Illuminate\Contracts\Notifications\Dispatcher::class); + + return $dispatcher; +} + +beforeEach(function () { + createOrderChatSchema(); + // Model events generate uuids and public ids, and run ChatChannel's creator hook. + Model::setEventDispatcher(new Illuminate\Events\Dispatcher(app())); + Model::clearBootedModels(); + app()->instance('responsecache', new class { + public function clear(): void + { + } + }); + Spatie\ResponseCache\Facades\ResponseCache::clearResolvedInstance('responsecache'); + // Core macros and a disabled activity log, as the framework would provide them. + if (!Illuminate\Support\Str::hasMacro('humanize')) { + Illuminate\Support\Str::macro('humanize', (new Fleetbase\Expansions\Str())->humanize()); + } + app()->instance(Spatie\Activitylog\ActivityLogStatus::class, new Spatie\Activitylog\ActivityLogStatus(new Illuminate\Config\Repository(['activitylog' => ['enabled' => false]]))); + $this->notifications = fakeChatNotifications(); +}); + +afterEach(function () { + Model::unsetEventDispatcher(); + Model::clearBootedModels(); + app()->offsetUnset(Illuminate\Contracts\Notifications\Dispatcher::class); +}); + +test('order chat requires a signed-in customer and their own order in this storefront', function () { + $controller = new OrderChatController(); + + bindOrderChatRequest(null); + $signedOut = $controller->show('order_active'); + bindOrderChatRequest(); + $missing = $controller->show('order_missing'); + $otherStore = $controller->show('order_other_store'); + $notMine = $controller->show('order_other_customer'); + bindOrderChatRequest('chat-secret', 'unknown_key'); + $noStorefront = $controller->show('order_active'); + + expect($signedOut->getStatusCode())->toBe(401) + ->and($missing->getStatusCode())->toBe(404) + ->and($otherStore->getStatusCode())->toBe(404) + ->and($notMine->getStatusCode())->toBe(403) + ->and($noStorefront->getStatusCode())->toBe(404); +}); + +test('order chat waits for a driver and stays closed to new chats after the order finishes', function () { + bindOrderChatRequest(); + $controller = new OrderChatController(); + + $waiting = $controller->show('order_waiting'); + $finished = $controller->show('order_done_never_chatted'); + + expect($waiting->getStatusCode())->toBe(409) + ->and($waiting->getData(true))->toBe([ + 'error' => 'You can message your driver once one is assigned to this order.', + 'reason' => 'driver_not_assigned', + ]) + ->and($finished->getStatusCode())->toBe(409) + ->and(ChatChannel::query()->count())->toBe(0); +}); + +test('opening an order chat creates one channel with the customer and driver', function () { + bindOrderChatRequest(); + $controller = new OrderChatController(); + + $first = orderChatData($controller->show('order_active')); + $second = orderChatData($controller->show('order_active')); + $channel = ChatChannel::query()->first(); + + expect(ChatChannel::query()->count())->toBe(1) + ->and($channel->name)->toBe('Order order_active') + ->and(data_get($channel->meta, 'storefront_order_uuid'))->toBe('order_active_uuid') + ->and(data_get($channel->meta, 'storefront_order_id'))->toBe('order_active') + ->and($first['id'])->toBe($channel->public_id) + ->and($first['channel'])->toBe('chat.' . $channel->public_id) + ->and($first['order'])->toBe('order_active') + ->and($first['status'])->toBe('open') + ->and($first['me'])->toBe(OrderChat::participantFor($channel, 'customer_user_uuid')->public_id) + ->and(collect($first['participants'])->pluck('role', 'name')->all())->toBe(['Mei Tan' => 'customer', 'Ravi K.' => 'driver']) + ->and($first['participants'][0]['is_online'])->toBeFalse() + ->and($first['participants'][0]['avatar_url'])->toBeNull() + ->and($first['messages'])->toBe([]) + ->and($first['unread_count'])->toBe(0) + ->and($second['id'])->toBe($first['id']); +}); + +test('network apps open chats for orders placed through the network', function () { + bindOrderChatRequest('chat-secret', 'network_key'); + + $response = orderChatData((new OrderChatController())->show('order_network')); + + expect($response['order'])->toBe('order_network'); +}); + +test('customers send text and photos, and only driver messages are pushed to the customer', function () { + $storage = new OrderChatFakeDisk(); + Illuminate\Support\Facades\Storage::swap($storage); + config(['filesystems.default' => 'public', 'filesystems.disks.public.bucket' => 'media-bucket']); + $controller = new OrderChatController(); + bindOrderChatRequest(); + $controller->show('order_active'); + $channel = ChatChannel::query()->first(); + + $request = bindOrderChatRequest('chat-secret', 'store_key', [ + 'content' => 'Tower B, unit 08-112', + 'files' => [['data' => base64_encode('photo-bytes'), 'type' => 'image/png']], + ], 'POST', SendOrderChatMessageRequest::class); + $sent = $controller->send($request, 'order_active')->getData(true)['message']; + $file = orderChatDb()->table('files')->first(); + + $driverMessage = driverSays($channel, '', now()->toDateTimeString()); + + expect($sent['content'])->toBe('Tower B, unit 08-112') + ->and($sent['is_mine'])->toBeTrue() + ->and($sent['sender']['role'])->toBe('customer') + ->and($sent['sender']['name'])->toBe('Mei Tan') + ->and($sent['read'])->toBeFalse() + ->and($sent['attachments'])->toHaveCount(1) + ->and($sent['attachments'][0]['type'])->toBe('image/png') + ->and($sent['attachments'][0]['url'])->toStartWith('https://cdn.test/hyperstore/store_bloom/order-chat/' . $channel->uuid . '/') + ->and($file->disk)->toBe('public') + ->and($file->bucket)->toBe('media-bucket') + ->and($file->type)->toBe('storefront_chat_upload') + ->and($file->file_size)->toBe(strlen('photo-bytes')) + ->and($file->subject_type)->toBe(ChatMessage::class) + ->and($storage->writes[0][1])->toBe('photo-bytes') + // chat photos are written without a public ACL + ->and($storage->writes[0][2])->toBe([]); + + // The customer's own message is not pushed back to them; the driver's is (here the + // observer is called directly, since this harness has no model event dispatcher). + (new ChatMessageObserver())->created($driverMessage); + $pushed = $this->notifications->sentTo(CHAT_CUSTOMER_UUID, StorefrontOrderChatMessage::class); + $driverNotified = $this->notifications->sentTo('driver_user_uuid', Fleetbase\Notifications\ChatMessageReceived::class); + + expect($pushed)->toHaveCount(1) + ->and($pushed[0]->message->uuid)->toBe($driverMessage->uuid) + ->and($driverNotified)->toHaveCount(1); +}); + +test('messages page backwards with a stable cursor and read receipts clear the unread count', function () { + $controller = new OrderChatController(); + bindOrderChatRequest(); + $controller->show('order_active'); + $channel = ChatChannel::query()->first(); + $first = driverSays($channel, 'Picked up your flowers', '2026-10-06 13:51:00'); + $second = driverSays($channel, 'At the lobby', '2026-10-06 14:00:00'); + $third = driverSays($channel, 'Which tower?', '2026-10-06 14:00:00'); + + $opened = orderChatData($controller->show('order_active')); + $page = $controller->messages(bindOrderChatRequest('chat-secret', 'store_key', ['before' => $third->public_id, 'limit' => 1]), 'order_active')->getData(true); + $earlier = $controller->messages(bindOrderChatRequest('chat-secret', 'store_key', ['before' => $second->public_id]), 'order_active')->getData(true); + $unknown = $controller->messages(bindOrderChatRequest('chat-secret', 'store_key', ['before' => 'chat_message_missing']), 'order_active'); + $all = $controller->messages(bindOrderChatRequest('chat-secret', 'store_key', ['limit' => 500]), 'order_active')->getData(true); + bindOrderChatRequest(); + $read = $controller->read('order_active')->getData(true); + $after = orderChatData($controller->show('order_active')); + $again = $controller->read('order_active')->getData(true); + + expect(collect($opened['messages'])->pluck('content')->all())->toBe(['Picked up your flowers', 'At the lobby', 'Which tower?']) + ->and($opened['messages'][0]['is_mine'])->toBeFalse() + ->and($opened['messages'][0]['sender']['role'])->toBe('driver') + ->and($opened['unread_count'])->toBe(3) + ->and(collect($page['messages'])->pluck('id')->all())->toBe([$second->public_id]) + ->and(collect($earlier['messages'])->pluck('id')->all())->toBe([$first->public_id]) + ->and($unknown->getStatusCode())->toBe(404) + ->and($all['messages'])->toHaveCount(3) + ->and($read)->toBe(['read' => 3]) + ->and($after['unread_count'])->toBe(0) + ->and($after['messages'][2]['read'])->toBeTrue() + ->and($again)->toBe(['read' => 0]); +}); + +test('reassigning the order moves the chat to the new driver', function () { + $controller = new OrderChatController(); + bindOrderChatRequest(); + $controller->show('order_active'); + orderChatDb()->table('orders')->where('public_id', 'order_active')->update(['driver_assigned_uuid' => 'second_driver_uuid']); + + $response = orderChatData($controller->show('order_active')); + + expect(collect($response['participants'])->pluck('name')->sort()->values()->all())->toBe(['Arun S.', 'Mei Tan']) + ->and(ChatChannel::query()->count())->toBe(1); +}); + +test('finished orders keep their chat readable but refuse new messages', function () { + $controller = new OrderChatController(); + bindOrderChatRequest(); + $controller->show('order_active'); + orderChatDb()->table('orders')->where('public_id', 'order_active')->update(['status' => 'completed']); + + $shown = orderChatData($controller->show('order_active')); + $request = bindOrderChatRequest('chat-secret', 'store_key', ['content' => 'Thanks!'], 'POST', SendOrderChatMessageRequest::class); + $sent = $controller->send($request, 'order_active'); + + expect($shown['status'])->toBe('closed') + ->and($sent->getStatusCode())->toBe(423) + ->and($sent->getData(true))->toBe(['error' => 'This chat closed when the order finished.', 'reason' => 'chat_closed']) + ->and(ChatMessage::query()->count())->toBe(0); +}); + +test('customers removed from a finished order chat cannot mark messages read until it is reopened', function () { + $controller = new OrderChatController(); + bindOrderChatRequest(); + $controller->show('order_active'); + $channel = ChatChannel::query()->first(); + // A channel whose customer participant is gone (for example removed by an operator) and + // that is not re-synced because the order has finished. + OrderChat::participantFor($channel, 'customer_user_uuid')->delete(); + orderChatDb()->table('orders')->where('public_id', 'order_active')->update(['status' => 'canceled']); + $read = $controller->read('order_active'); + orderChatDb()->table('orders')->where('public_id', 'order_active')->update(['status' => 'dispatched']); + $channel->refresh(); + + expect($read->getStatusCode())->toBe(403) + ->and(OrderChat::participantFor($channel, null))->toBeNull(); + + // Re-opening the chat for an active order restores the customer. + bindOrderChatRequest(); + $controller->show('order_active'); + expect(OrderChat::participantFor($channel, 'customer_user_uuid'))->not->toBeNull(); +}); + +test('a customer missing from an open chat cannot send messages', function () { + $controller = new class extends OrderChatController { + protected function resolveContext(string $id): array|Illuminate\Http\JsonResponse + { + $context = parent::resolveContext($id); + OrderChat::participantFor($context[2], 'customer_user_uuid')?->delete(); + + return $context; + } + }; + $request = bindOrderChatRequest('chat-secret', 'store_key', ['content' => 'Hi'], 'POST', SendOrderChatMessageRequest::class); + + $sent = $controller->send($request, 'order_active'); + $shown = orderChatData($controller->show('order_active')); + + expect($sent->getStatusCode())->toBe(403) + ->and($shown['unread_count'])->toBe(0) + ->and($shown['me'])->toBeNull(); +}); + +test('every chat action reports a missing context the same way', function () { + $controller = new OrderChatController(); + bindOrderChatRequest(null); + + expect($controller->messages(request(), 'order_active')->getStatusCode())->toBe(401) + ->and($controller->read('order_active')->getStatusCode())->toBe(401) + ->and($controller->send(SendOrderChatMessageRequest::create('/', 'POST', ['content' => 'x']), 'order_active')->getStatusCode())->toBe(401); +}); + +test('the chat observer ignores non-order chats, missing orders and customers, and customer messages', function () { + $observer = new ChatMessageObserver(); + $plain = ChatChannel::create(['company_uuid' => 'company_uuid', 'created_by_uuid' => 'driver_user_uuid', 'name' => 'Dispatch']); + $orphan = ChatChannel::create(['company_uuid' => 'company_uuid', 'created_by_uuid' => 'driver_user_uuid', 'name' => 'Old order', 'meta' => ['storefront_order_uuid' => 'gone_uuid']]); + $sender = ChatParticipant::where('chat_channel_uuid', $plain->uuid)->first(); + + $observer->created(new ChatMessage(['chat_channel_uuid' => 'missing_channel'])); + $observer->created(new ChatMessage(['chat_channel_uuid' => $plain->uuid, 'sender_uuid' => $sender->uuid])); + $observer->created(new ChatMessage(['chat_channel_uuid' => $orphan->uuid, 'sender_uuid' => $sender->uuid])); + + $order = Order::where('public_id', 'order_active')->first(); + $chat = OrderChat::open($order); + $customerParticipant = OrderChat::participantFor($chat, 'customer_user_uuid'); + $observer->created(new ChatMessage(['chat_channel_uuid' => $chat->uuid, 'sender_uuid' => $customerParticipant->uuid])); + $observer->created(new ChatMessage(['chat_channel_uuid' => $chat->uuid, 'sender_uuid' => 'missing_participant'])); + orderChatDb()->table('orders')->where('public_id', 'order_active')->update(['customer_uuid' => 'nobody']); + $observer->created(new ChatMessage(['chat_channel_uuid' => $chat->uuid, 'sender_uuid' => $customerParticipant->uuid])); + + expect($this->notifications->sent)->toBe([]); +}); + +test('order chat helpers find the order and refuse to open without accounts', function () { + $order = Order::where('public_id', 'order_active')->first(); + $chat = OrderChat::open($order); + orderChatDb()->table('contacts')->where('uuid', CHAT_CUSTOMER_UUID)->update(['user_uuid' => null]); + $guest = Order::where('public_id', 'order_active')->first(); + + expect(OrderChat::orderFor($chat)->uuid)->toBe('order_active_uuid') + ->and(OrderChat::orderFor(new ChatChannel()))->toBeNull() + ->and(OrderChat::find($order)->uuid)->toBe($chat->uuid) + ->and(OrderChat::open($guest))->toBeNull() + ->and(OrderChat::isClosed($order))->toBeFalse(); +}); + +test('chat notifications describe the driver message for push, inbox and sockets', function () { + $order = Order::where('public_id', 'order_network')->first(); + $chat = OrderChat::open($order); + $message = driverSays($chat, 'At the lobby', now()->toDateTimeString()); + $photo = driverSays($chat, ' ', now()->toDateTimeString()); + $anonymous = new ChatMessage(['content' => 'Hi']); + + $notification = new StorefrontOrderChatMessage($message->load(['sender.user', 'chatChannel']), $order); + $array = $notification->toArray(null); + $push = $notification->toPush(null); + + expect($notification->via(null))->toBe([Fleetbase\Storefront\Push\StorefrontPushChannel::class, 'database', Fleetbase\Storefront\Notifications\Channels\SafeBroadcastChannel::class]) + ->and($array)->toMatchArray([ + 'title' => 'Ravi K. sent a message', + 'body' => 'At the lobby', + 'type' => 'order_chat_message', + 'order_id' => 'order_network', + 'chat_id' => $chat->public_id, + 'message_id' => $message->public_id, + 'store_id' => 'store_bloom', + 'network_id' => 'network_market', + ]) + ->and($push)->toBeInstanceOf(Fleetbase\Storefront\Push\PushMessage::class) + ->and($notification->broadcastType())->toBe('order_chat_message') + ->and($notification->toBroadcast(null))->toBeInstanceOf(Illuminate\Notifications\Messages\BroadcastMessage::class) + ->and($notification->pushStorefronts())->toBeArray() + ->and((new StorefrontOrderChatMessage($photo, $order))->body())->toBe('Sent a photo') + ->and((new StorefrontOrderChatMessage($anonymous, $order))->title())->toBe('Your driver sent a message'); +}); + +test('send message requests need text or photos', function () { + session(['storefront_key' => 'store_key']); + $rules = (new SendOrderChatMessageRequest())->rules(); + session(['storefront_key' => null]); + + expect($rules)->toBe([ + 'content' => 'required_without:files|nullable|string|max:2000', + 'files' => 'required_without:content|array|max:4', + 'files.*.data' => 'required_with:files|string', + 'files.*.type' => 'required_with:files|string|starts_with:image/', + ]) + ->and((new SendOrderChatMessageRequest())->authorize())->toBeFalse(); +}); + +test('assigning a driver to a storefront order starts its chat', function () { + $event = (new ReflectionClass(Fleetbase\FleetOps\Events\OrderDriverAssigned::class))->newInstanceWithoutConstructor(); + $event->modelUuid = 'order_active_uuid'; + $event->modelClassNamespace = Order::class; + + (new Fleetbase\Storefront\Listeners\HandleOrderDriverAssigned())->handle($event); + + expect(ChatChannel::query()->count())->toBe(1) + ->and($this->notifications->sentTo(CHAT_CUSTOMER_UUID, Fleetbase\Storefront\Notifications\StorefrontOrderDriverAssigned::class))->toHaveCount(1); +}); + +test('a failure to start the chat does not stop the driver assignment notification', function () { + orderChatDb()->getSchemaBuilder()->drop('chat_channels'); + $reported = []; + app()->instance(Illuminate\Contracts\Debug\ExceptionHandler::class, new class($reported) implements Illuminate\Contracts\Debug\ExceptionHandler { + public function __construct(public array &$reported) + { + } + + public function report(Throwable $e) + { + $this->reported[] = $e; + } + + public function shouldReport(Throwable $e) + { + return true; + } + + public function render($request, Throwable $e) + { + throw $e; + } + + public function renderForConsole($output, Throwable $e) + { + } + }); + $event = (new ReflectionClass(Fleetbase\FleetOps\Events\OrderDriverAssigned::class))->newInstanceWithoutConstructor(); + $event->modelUuid = 'order_active_uuid'; + $event->modelClassNamespace = Order::class; + + (new Fleetbase\Storefront\Listeners\HandleOrderDriverAssigned())->handle($event); + app()->offsetUnset(Illuminate\Contracts\Debug\ExceptionHandler::class); + + expect($reported)->toHaveCount(1) + ->and($this->notifications->sentTo(CHAT_CUSTOMER_UUID, Fleetbase\Storefront\Notifications\StorefrontOrderDriverAssigned::class))->toHaveCount(1); +}); diff --git a/server/tests/Unit/Http/Controllers/PublicCommerceControllerContractsTest.php b/server/tests/Unit/Http/Controllers/PublicCommerceControllerContractsTest.php index a4adbdd2..f6ae8266 100644 --- a/server/tests/Unit/Http/Controllers/PublicCommerceControllerContractsTest.php +++ b/server/tests/Unit/Http/Controllers/PublicCommerceControllerContractsTest.php @@ -38,6 +38,13 @@ public function empty() return $this; } + public function clear(): Fleetbase\Storefront\Models\Cart + { + $this->calls['clear'] = true; + + return $this; + } + public function delete() { $this->calls['delete'] = true; @@ -70,6 +77,7 @@ function createPublicCartControllerSchema(): void $table->string('company_uuid')->nullable(); $table->string('user_uuid')->nullable(); $table->string('checkout_uuid')->nullable(); + $table->string('status')->nullable(); $table->string('customer_id')->nullable(); $table->string('unique_identifier')->nullable(); $table->string('currency')->nullable(); @@ -404,7 +412,9 @@ function createPublicCartControllerSchema(): void '2026-07-29 10:00:00', ]) ->and($cart->calls['remove'])->toBe(['line_item_abcdefgh']) - ->and($cart->calls['empty'])->toBeTrue() + // Emptying closes the cart (keeping its items) rather than wiping it. + ->and($cart->calls['clear'])->toBeTrue() + ->and($cart->calls)->not->toHaveKey('empty') ->and($cart->calls['delete'])->toBeTrue(); }); diff --git a/server/tests/Unit/Http/Controllers/ReviewAndOrderControllerContractsTest.php b/server/tests/Unit/Http/Controllers/ReviewAndOrderControllerContractsTest.php index 14c86195..2baa9dcd 100644 --- a/server/tests/Unit/Http/Controllers/ReviewAndOrderControllerContractsTest.php +++ b/server/tests/Unit/Http/Controllers/ReviewAndOrderControllerContractsTest.php @@ -281,6 +281,33 @@ public function unassignCurrentOrder(): void } } +function createReviewOrderSchema(): void +{ + $schema = Model::getConnectionResolver()->connection('mysql')->getSchemaBuilder(); + $schema->dropIfExists('orders'); + $schema->dropIfExists('entities'); + $schema->create('orders', function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('public_id')->nullable(); + $table->string('company_uuid')->nullable(); + $table->string('customer_uuid')->nullable(); + $table->string('payload_uuid')->nullable(); + $table->string('status')->nullable(); + $table->text('meta')->nullable(); + $table->timestamp('created_at')->nullable(); + $table->timestamp('updated_at')->nullable(); + $table->timestamp('deleted_at')->nullable(); + }); + $schema->create('entities', function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('payload_uuid')->nullable(); + $table->string('internal_id')->nullable(); + $table->timestamp('deleted_at')->nullable(); + }); +} + function createReviewControllerSchema(): void { $schema = Model::getConnectionResolver()->connection('mysql')->getSchemaBuilder(); @@ -292,6 +319,7 @@ function createReviewControllerSchema(): void $table->string('public_id')->nullable(); $table->string('created_by_uuid')->nullable(); $table->string('customer_uuid')->nullable(); + $table->string('order_uuid')->nullable(); $table->string('subject_uuid')->nullable(); $table->string('subject_type')->nullable(); $table->integer('rating')->nullable(); @@ -309,7 +337,7 @@ function createReviewControllerSchema(): void }); } -test('review sort aliases map to stable API sort fields and directions', function (string $sort, ?array $expected) { +test('review sort aliases map to the sort columns core understands, with "-" for descending', function (string $sort, ?array $expected) { $request = Request::create('/reviews'); (new ReviewController())->applySort($request, $sort); @@ -317,13 +345,14 @@ function createReviewControllerSchema(): void if ($expected === null) { expect($request->has('sort'))->toBeFalse(); } else { - expect($request->only(['sort', 'sort_direction']))->toBe($expected); + expect($request->input('sort'))->toBe($expected) + ->and($request->has('sort_direction'))->toBeFalse(); } })->with([ - 'highest' => ['highest rated', ['sort' => 'rating', 'sort_direction' => 'desc']], - 'lowest' => ['lowest', ['sort' => 'rating', 'sort_direction' => 'asc']], - 'newest' => ['newest first', ['sort' => 'created_at', 'sort_direction' => 'desc']], - 'oldest' => ['oldest', ['sort' => 'created_at', 'sort_direction' => 'asc']], + 'highest' => ['highest rated', ['-rating', '-created_at']], + 'lowest' => ['lowest', ['rating', '-created_at']], + 'newest' => ['newest first', ['-created_at']], + 'oldest' => ['oldest', ['created_at']], 'unknown' => ['featured', null], ]); @@ -418,8 +447,7 @@ function createReviewControllerSchema(): void $resource = (new ReviewController())->query($request); - expect($request->input('sort'))->toBe('rating') - ->and($request->input('sort_direction'))->toBe('desc') + expect($request->input('sort'))->toBe(['-rating', '-created_at']) ->and($resource->resource)->toHaveCount(1) ->and($resource->resource->first()->uuid)->toBe('review_two_uuid'); }); @@ -637,9 +665,10 @@ function createReviewControllerSchema(): void createReviewControllerSchema(); $connection = Model::getConnectionResolver()->connection('mysql'); $schema = $connection->getSchemaBuilder(); - foreach (['personal_access_tokens', 'files', 'contacts', 'stores'] as $table) { + foreach (['personal_access_tokens', 'files', 'contacts', 'stores', 'orders', 'entities'] as $table) { $schema->dropIfExists($table); } + createReviewOrderSchema(); $schema->create('personal_access_tokens', function ($table) { $table->increments('id'); $table->string('tokenable_type')->nullable(); @@ -728,6 +757,14 @@ function createReviewControllerSchema(): void ['uuid' => 'product_uuid', 'public_id' => 'product_abcdefgh', 'store_uuid' => 'store_uuid'], ['uuid' => 'foreign_product_uuid', 'public_id' => 'product_foreign', 'store_uuid' => 'foreign_store_uuid'], ]); + // Two completed orders from the store and one containing the product: each order can be + // reviewed once per subject, newest first. + $connection->table('orders')->insert([ + ['uuid' => 'older_store_order_uuid', 'public_id' => 'order_older', 'customer_uuid' => $customerUuid, 'status' => 'completed', 'payload_uuid' => null, 'meta' => json_encode(['storefront_id' => 'store_abcdefgh']), 'created_at' => now()->subDays(3)], + ['uuid' => 'newer_store_order_uuid', 'public_id' => 'order_newer', 'customer_uuid' => $customerUuid, 'status' => 'completed', 'payload_uuid' => null, 'meta' => json_encode(['storefront_id' => 'store_abcdefgh']), 'created_at' => now()->subDay()], + ['uuid' => 'product_order_uuid', 'public_id' => 'order_product', 'customer_uuid' => $customerUuid, 'status' => 'completed', 'payload_uuid' => 'payload_uuid', 'meta' => json_encode(['storefront_id' => 'other_store']), 'created_at' => now()->subDays(2)], + ]); + $connection->table('entities')->insert(['uuid' => 'entity_uuid', 'payload_uuid' => 'payload_uuid', 'internal_id' => 'product_abcdefgh']); $boundRequest = Request::create('/reviews'); $boundRequest->headers->set('Customer-Token', 'review-customer-secret'); $boundRequest->setLaravelSession(new Illuminate\Session\Store( @@ -774,17 +811,22 @@ function createReviewControllerSchema(): void 'uuid' => 'owned_review_uuid', 'public_id' => 'review_owned', ]); - Illuminate\Support\Facades\Storage::swap(new class { + $reviewStorage = new class { public function disk(string $disk): self { return $this; } - public function put(string $path, string $contents, string $visibility): bool + public array $writes = []; + + public function put(string $path, string $contents, mixed $options = []): bool { + $this->writes[] = [$path, $options]; + return true; } - }); + }; + Illuminate\Support\Facades\Storage::swap($reviewStorage); session(['storefront_key' => 'store_key']); $withPhoto = $controller->create( Fleetbase\Storefront\Http\Requests\CreateReviewRequest::create('/reviews', 'POST', [ @@ -801,7 +843,14 @@ public function put(string $path, string $contents, string $visibility): bool ], ]) ); - $photo = $connection->table('files')->first(); + $photo = $connection->table('files')->first(); + $alreadyReviewed = $controller->create( + Fleetbase\Storefront\Http\Requests\CreateReviewRequest::create('/reviews', 'POST', [ + 'subject' => 'store_abcdefgh', + 'rating' => 3, + 'content' => 'Third review', + ]) + ); session(['storefront_store' => 'store_uuid', 'storefront_network' => null]); $deleted = $controller->delete('review_owned'); @@ -814,12 +863,23 @@ public function put(string $path, string $contents, string $visibility): bool ->and($review->subject_uuid)->toBe('store_uuid') ->and($review->rating)->toBe(5) ->and($review->content)->toBe('Excellent service') + ->and($review->order_uuid)->toBe('newer_store_order_uuid') + ->and($createdProduct->resource->order_uuid)->toBe('product_order_uuid') + ->and($withPhoto->resource->order_uuid)->toBe('older_store_order_uuid') + ->and($alreadyReviewed->getStatusCode())->toBe(403) + ->and($alreadyReviewed->getData(true))->toBe([ + 'error' => 'You have already reviewed this for your completed orders.', + 'reason' => 'already_reviewed', + ]) ->and($withPhoto->resource->files)->toHaveCount(1) ->and($photo->subject_uuid)->toBe($withPhoto->resource->uuid) ->and($photo->content_type)->toBe('image/png') ->and($photo->bucket)->toBe('review-bucket') ->and($photo->file_size)->toBe(strlen('image-bytes')) ->and($photo->type)->toBe('storefront_review_upload') + // review photos are written without a 'public' visibility/ACL + ->and($reviewStorage->writes)->toHaveCount(1) + ->and($reviewStorage->writes[0][1])->toBe([]) ->and($deleted->resource->uuid)->toBe('owned_review_uuid') ->and($connection->table('reviews')->where('uuid', 'owned_review_uuid')->value('deleted_at'))->not->toBeNull(); }); @@ -1352,3 +1412,120 @@ public function put(string $path, string $contents, string $visibility): bool 'order' => 'order_uuid', ]))['status'])->toBe('canceled'); }); + +test('a customer gets their own order as steps from its order config', function () { + $connection = Model::getConnectionResolver()->connection('mysql'); + $schema = $connection->getSchemaBuilder(); + foreach (['personal_access_tokens', 'contacts', 'orders', 'order_configs', 'tracking_statuses'] as $table) { + $schema->dropIfExists($table); + } + $schema->create('personal_access_tokens', function ($table) { + $table->increments('id'); + $table->string('tokenable_type')->nullable(); + $table->integer('tokenable_id')->nullable(); + $table->string('name'); + $table->string('token'); + $table->text('abilities')->nullable(); + $table->timestamp('last_used_at')->nullable(); + $table->timestamp('expires_at')->nullable(); + $table->timestamps(); + }); + $schema->create('contacts', function ($table) { + $table->increments('id'); + $table->string('uuid'); + $table->string('public_id')->nullable(); + $table->string('type')->nullable(); + $table->timestamp('deleted_at')->nullable(); + }); + $schema->create('orders', function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('public_id')->nullable(); + $table->string('customer_uuid')->nullable(); + $table->string('order_config_uuid')->nullable(); + $table->string('tracking_number_uuid')->nullable(); + $table->string('status')->nullable(); + $table->text('meta')->nullable(); + $table->timestamps(); + $table->timestamp('deleted_at')->nullable(); + }); + $schema->create('order_configs', function ($table) { + $table->increments('id'); + $table->string('uuid'); + $table->string('public_id')->nullable(); + $table->string('key')->nullable(); + $table->string('name')->nullable(); + $table->text('flow')->nullable(); + }); + $schema->create('tracking_statuses', function ($table) { + $table->increments('id'); + $table->string('tracking_number_uuid')->nullable(); + $table->string('code')->nullable(); + $table->string('status')->nullable(); + $table->timestamp('created_at')->nullable(); + $table->timestamp('deleted_at')->nullable(); + }); + $connection->table('contacts')->insert(['uuid' => '11111111-1111-4111-8111-111111111111', 'public_id' => 'contact_customer', 'type' => 'customer']); + $connection->table('personal_access_tokens')->insert([ + 'name' => '11111111-1111-4111-8111-111111111111', + 'token' => hash('sha256', 'customer-secret'), + 'abilities' => '["*"]', + 'created_at' => now(), + 'updated_at' => now(), + ]); + // A branch whose condition can't be evaluated is skipped rather than failing the request. + $connection->table('order_configs')->insert([ + 'uuid' => 'config_uuid', + 'public_id' => 'order_config_olimax', + 'key' => 'olimax', + 'name' => 'Oli Max delivery', + 'flow' => json_encode([ + 'created' => ['code' => 'created', 'status' => 'Created', 'activities' => ['dispatched']], + 'dispatched' => ['code' => 'dispatched', 'status' => 'Dispatched', 'details' => 'Sent from {storefront.name}', 'activities' => ['unsupported', 'started']], + 'unsupported' => ['code' => 'unsupported', 'status' => 'Unsupported', 'logic' => [['type' => 'not-a-logic-type', 'conditions' => []]]], + 'started' => ['code' => 'started', 'status' => 'Started', 'activities' => ['completed']], + 'completed' => ['code' => 'completed', 'status' => 'Completed', 'complete' => true], + ]), + ]); + $connection->table('orders')->insert([ + ['uuid' => 'mine_uuid', 'public_id' => 'order_mine', 'customer_uuid' => '11111111-1111-4111-8111-111111111111', 'order_config_uuid' => 'config_uuid', 'tracking_number_uuid' => 'tracking_uuid', 'status' => 'dispatched', 'meta' => json_encode(['storefront' => 'Oli Max']), 'created_at' => '2026-10-08 10:00:00', 'updated_at' => now(), 'deleted_at' => null], + ['uuid' => 'bare_uuid', 'public_id' => 'order_bare', 'customer_uuid' => '11111111-1111-4111-8111-111111111111', 'order_config_uuid' => null, 'tracking_number_uuid' => null, 'status' => 'created', 'meta' => '{}', 'created_at' => null, 'updated_at' => null, 'deleted_at' => null], + ['uuid' => 'theirs_uuid', 'public_id' => 'order_theirs', 'customer_uuid' => '22222222-2222-4222-8222-222222222222', 'order_config_uuid' => null, 'tracking_number_uuid' => null, 'status' => 'created', 'meta' => '{}', 'created_at' => null, 'updated_at' => null, 'deleted_at' => null], + ]); + $connection->table('tracking_statuses')->insert([ + ['tracking_number_uuid' => 'tracking_uuid', 'code' => 'CREATED', 'status' => 'Created', 'created_at' => '2026-10-08 10:00:00', 'deleted_at' => null], + ['tracking_number_uuid' => 'tracking_uuid', 'code' => 'DISPATCHED', 'status' => 'Dispatched', 'created_at' => '2026-10-08 10:05:00', 'deleted_at' => null], + ['tracking_number_uuid' => 'tracking_uuid', 'code' => 'REMOVED', 'status' => 'Removed', 'created_at' => '2026-10-08 10:06:00', 'deleted_at' => now()], + ['tracking_number_uuid' => 'tracking_uuid', 'code' => 'NO_TIME', 'status' => 'No time', 'created_at' => null, 'deleted_at' => null], + ]); + + $signedOut = Request::create('/orders'); + $signedOut->setLaravelSession(new Illuminate\Session\Store('activity-flow-signed-out', new Illuminate\Session\ArraySessionHandler(120))); + app()->instance('request', $signedOut); + $unauthenticated = (new OrderController())->getActivityFlow('order_mine'); + + $boundRequest = Request::create('/orders'); + $boundRequest->headers->set('Customer-Token', 'customer-secret'); + $boundRequest->setLaravelSession(new Illuminate\Session\Store('activity-flow-test', new Illuminate\Session\ArraySessionHandler(120))); + app()->instance('request', $boundRequest); + $controller = new OrderController(); + + $missing = $controller->getActivityFlow('order_missing'); + $unauthorized = $controller->getActivityFlow('order_theirs'); + $flow = $controller->getActivityFlow('order_mine')->getData(true); + $bare = $controller->getActivityFlow('order_bare')->getData(true); + + expect($unauthenticated->getData(true))->toBe(['error' => 'Customer is not authenticated.']) + ->and($missing->getStatusCode())->toBe(404) + ->and($unauthorized->getStatusCode())->toBe(403) + ->and($unauthorized->getData(true))->toBe(['error' => 'Not authorized to view this order.']) + ->and($flow['order'])->toBe('order_mine') + ->and($flow['order_config'])->toBe(['id' => 'order_config_olimax', 'key' => 'olimax', 'name' => 'Oli Max delivery']) + // History without a time sorts first; deleted history is left out. + ->and(array_map(fn ($step) => $step['code'] . ':' . $step['state'], $flow['steps']))->toBe(['no_time:done', 'created:done', 'dispatched:current', 'started:upcoming', 'completed:upcoming']) + ->and($flow['steps'][2]['details'])->toBe('Sent from Oli Max') + ->and($flow['steps'][2]['reached_at'])->toStartWith('2026-10-08T10:05:00') + ->and($flow['steps'][0]['reached_at'])->toBeNull() + ->and($bare['order_config'])->toBeNull() + ->and(array_map(fn ($step) => $step['code'] . ':' . $step['state'], $bare['steps']))->toBe(['created:current']); +}); diff --git a/server/tests/Unit/Http/Controllers/ServiceQuoteControllerContractsTest.php b/server/tests/Unit/Http/Controllers/ServiceQuoteControllerContractsTest.php index c967b641..b2bbfcff 100644 --- a/server/tests/Unit/Http/Controllers/ServiceQuoteControllerContractsTest.php +++ b/server/tests/Unit/Http/Controllers/ServiceQuoteControllerContractsTest.php @@ -227,6 +227,7 @@ function createServiceQuoteLookupSchema(): void $table->string('company_uuid')->nullable(); $table->string('user_uuid')->nullable(); $table->string('checkout_uuid')->nullable(); + $table->string('status')->nullable(); $table->string('customer_id')->nullable(); $table->string('unique_identifier')->nullable(); $table->string('currency')->nullable(); diff --git a/server/tests/Unit/Http/Controllers/StoreQueryContractsTest.php b/server/tests/Unit/Http/Controllers/StoreQueryContractsTest.php new file mode 100644 index 00000000..92da6136 --- /dev/null +++ b/server/tests/Unit/Http/Controllers/StoreQueryContractsTest.php @@ -0,0 +1,22 @@ +onQueryRecord($plain, Request::create('/int/v1/stores', 'GET')); + + $withCategory = Store::query(); + $controller->onQueryRecord($withCategory, Request::create('/int/v1/stores', 'GET', [ + 'network' => 'network_uuid', + 'with_category' => 1, + ])); + + expect(array_keys($plain->getEagerLoads()))->toBe(['logo', 'backdrop']) + ->and($plain->toSql())->toContain('reviews_avg_rating') + ->and(array_keys($withCategory->getEagerLoads()))->toBe(['logo', 'backdrop', 'networks']); +}); diff --git a/server/tests/Unit/Http/Requests/RequestContractsTest.php b/server/tests/Unit/Http/Requests/RequestContractsTest.php index d8840dd9..c9ad3752 100644 --- a/server/tests/Unit/Http/Requests/RequestContractsTest.php +++ b/server/tests/Unit/Http/Requests/RequestContractsTest.php @@ -152,12 +152,17 @@ // `subject` is required: the controller resolves it with Utils::resolveSubject(), // whose parameter is a non-nullable string, so omitting it threw a TypeError as a // 500 before the controller's own "Invalid subject for review" guard could run. + // Ratings are whole stars from 1 to 5, and a review carries at most four photos or + // videos. `order` optionally names the completed order being reviewed. ->and($reviewRules)->toBe([ - 'subject' => 'required|string', - 'rating' => 'required|numeric', - 'content' => 'required', - 'files' => 'sometimes|array', - 'rejected' => 'sometimes|boolean', + 'subject' => 'required|string', + 'order' => 'sometimes|nullable|string', + 'rating' => 'required|integer|between:1,5', + 'content' => 'required|string|max:2000', + 'files' => 'sometimes|array|max:4', + 'files.*.data' => 'required_with:files|string', + 'files.*.type' => 'required_with:files|string|starts_with:image/,video/', + 'rejected' => 'sometimes|boolean', ])->and($verificationRules)->toBe([ 'mode' => 'required|in:email,sms', 'identity' => 'required', diff --git a/server/tests/Unit/Http/Resources/ProductResourceTest.php b/server/tests/Unit/Http/Resources/ProductResourceTest.php index 8d87e8cc..29300c13 100644 --- a/server/tests/Unit/Http/Resources/ProductResourceTest.php +++ b/server/tests/Unit/Http/Resources/ProductResourceTest.php @@ -53,6 +53,8 @@ function storefrontProductResourceFixture(): ProductModel 'category_uuid' => 'addon_category_uuid', 'name' => 'Packaging', 'excluded_addons' => [], + 'is_required' => 1, + 'max_selectable' => '2', 'category' => (object) [ 'public_id' => 'addon_category_123', 'description'=> 'Packaging choices', @@ -155,9 +157,11 @@ function storefrontProductResourceFixture(): ProductModel 'files', 'type', ])->and($data['addon_categories'][0])->toMatchArray([ - 'id' => 'addon_category_123', - 'name' => 'Packaging', - 'description' => 'Packaging choices', + 'id' => 'addon_category_123', + 'name' => 'Packaging', + 'description' => 'Packaging choices', + 'is_required' => true, + 'max_selectable' => 2, ])->and($data['addon_categories'][0]['addons'][0])->toMatchArray([ 'id' => 'addon_123', 'name' => 'Gift wrap', @@ -188,11 +192,13 @@ function storefrontProductResourceFixture(): ProductModel ])->and($data)->toHaveKey('files') ->and($data)->not->toHaveKeys(['images', 'videos']) ->and($data['addon_categories'][0])->toMatchArray([ - 'id' => 20, - 'uuid' => 'product_addon_category_uuid', - 'product_uuid' => 'product_uuid', - 'category_uuid' => 'addon_category_uuid', - 'public_id' => 'addon_category_123', + 'id' => 20, + 'uuid' => 'product_addon_category_uuid', + 'product_uuid' => 'product_uuid', + 'category_uuid' => 'addon_category_uuid', + 'public_id' => 'addon_category_123', + 'is_required' => true, + 'max_selectable' => '2', ])->and($data['addon_categories'][0])->not->toHaveKey('addons') ->and($data['variants'][0])->toMatchArray([ 'id' => 40, @@ -244,3 +250,21 @@ function storefrontProductResourceFixture(): ProductModel 'name' => 'Gift wrap', ]); }); + +test('public add-on categories report optional unlimited choices when limits are unset', function () { + setStorefrontResourceRoute('v1/storefront/products'); + + $category = (object) [ + 'name' => 'Extras', + 'excluded_addons' => null, + 'is_required' => null, + 'max_selectable' => null, + 'category' => (object) ['public_id' => 'addon_category_456', 'description' => null, 'addons' => collect()], + ]; + + expect((new ProductResource((object) []))->mapAddonCategories([$category])->first())->toMatchArray([ + 'id' => 'addon_category_456', + 'is_required' => false, + 'max_selectable' => null, + ]); +}); diff --git a/server/tests/Unit/Http/Resources/RemainingResourceContractsTest.php b/server/tests/Unit/Http/Resources/RemainingResourceContractsTest.php index f81cfcd8..d83b7258 100644 --- a/server/tests/Unit/Http/Resources/RemainingResourceContractsTest.php +++ b/server/tests/Unit/Http/Resources/RemainingResourceContractsTest.php @@ -374,10 +374,27 @@ public function count(): int expect($data)->toMatchArray([ 'id' => 'customer_public', - 'name' => 'Ada Buyer', + 'name' => 'Ada B.', 'reviews_count' => 4, 'uploads_count' => 6, - ])->and($data)->not->toHaveKeys(['uuid', 'public_id']); + ])->and($data)->not->toHaveKeys(['uuid', 'public_id', 'email', 'phone']); + + $internal = (new ReviewCustomerResource($customer)) + ->resolve(setSimpleStorefrontResourceRoute('int/v1/storefront/review-customers')); + + expect($internal)->toMatchArray([ + 'name' => 'Ada Buyer', + 'email' => 'ada@example.test', + 'phone' => '+15550100', + ]); +}); + +test('review customers are named by first name and last initial in public', function () { + expect(ReviewCustomerResource::publicName(' ada van buyer '))->toBe('ada B.') + ->and(ReviewCustomerResource::publicName('Ada'))->toBe('Ada') + ->and(ReviewCustomerResource::publicName('Ölga ölund'))->toBe('Ölga Ö.') + ->and(ReviewCustomerResource::publicName(' '))->toBeNull() + ->and(ReviewCustomerResource::publicName(null))->toBeNull(); }); test('store hour and location resources preserve customer-facing scheduling shapes', function () { diff --git a/server/tests/Unit/Http/Resources/SimpleResourceContractsTest.php b/server/tests/Unit/Http/Resources/SimpleResourceContractsTest.php index 3d55ff3a..749f9cf2 100644 --- a/server/tests/Unit/Http/Resources/SimpleResourceContractsTest.php +++ b/server/tests/Unit/Http/Resources/SimpleResourceContractsTest.php @@ -158,27 +158,33 @@ public function uri(): string 'name' => 'Ada Lovelace', ]); $review = storefrontResourceModel([ - 'id' => 4, - 'uuid' => 'review_uuid', - 'public_id' => 'review_123', + 'id' => 4, + 'uuid' => 'review_uuid', + 'public_id' => 'review_123', + 'customer_uuid' => 'customer_uuid', + 'order_uuid' => 'order_uuid', 'rating' => 5, 'content' => 'Excellent service', 'slug' => 'excellent-service', 'created_at' => '2026-01-01', 'updated_at' => '2026-01-02', ], [ - 'subject' => storefrontResourceModel(['id' => 99]), + 'subject' => storefrontResourceModel(['id' => 99, 'public_id' => 'store_123']), 'customer' => $customer, 'photos' => collect([$photo]), ]); $reviewData = (new ReviewResource($review))->toArray(setSimpleStorefrontResourceRoute('v1/storefront/reviews')); + // Public responses name the subject by its public id, never the internal row id. expect($reviewData)->toMatchArray([ - 'id' => 'review_123', - 'subject_id' => 99, - 'rating' => 5, - 'content' => 'Excellent service', + 'id' => 'review_123', + 'subject_id' => 'store_123', + 'subject_type' => 'store', + 'verified' => true, + 'is_mine' => false, + 'rating' => 5, + 'content' => 'Excellent service', ])->and($reviewData['photos'][0])->toBe([ 'id' => 'file_123', 'filename' => 'receipt.jpg', diff --git a/server/tests/Unit/Models/CartTest.php b/server/tests/Unit/Models/CartTest.php index da61660e..41d65754 100644 --- a/server/tests/Unit/Models/CartTest.php +++ b/server/tests/Unit/Models/CartTest.php @@ -46,6 +46,7 @@ function createCartLifecycleSchema(): void $table->string('company_uuid')->nullable(); $table->string('user_uuid')->nullable(); $table->string('checkout_uuid')->nullable(); + $table->string('status')->nullable(); $table->string('customer_id')->nullable(); $table->string('unique_identifier')->nullable(); $table->string('currency')->nullable(); @@ -314,3 +315,76 @@ function cartWithItems(): Cart ->and($cart->currency)->toBeNull() ->and($cart->last_event->event)->toBe('cart.emptied'); }); + +function insertLifecycleCart(array $attributes): void +{ + Capsule::connection('mysql')->table('carts')->insert(array_merge([ + 'items' => '[]', + 'events' => '[]', + 'created_at' => now(), + 'updated_at' => now(), + ], $attributes)); +} + +test('cart retrieval keeps a device on its open cart and never returns a closed one', function () { + createCartLifecycleSchema(); + insertLifecycleCart(['uuid' => 'checked_uuid', 'public_id' => 'cart_checked', 'unique_identifier' => 'device-1', 'checkout_uuid' => 'checkout_uuid', 'status' => 'checked_out', 'items' => '[{"id":"cart_item_1","subtotal":1000}]', 'created_at' => now()->subHour()]); + insertLifecycleCart(['uuid' => 'open_uuid', 'public_id' => 'cart_open', 'unique_identifier' => 'device-1', 'status' => 'open']); + insertLifecycleCart(['uuid' => 'legacy_uuid', 'public_id' => 'cart_legacy', 'unique_identifier' => 'device-2', 'status' => null]); + + expect(Cart::retrieve('device-1')->public_id)->toBe('cart_open') + // an app still holding the checked-out cart continues with the device's open cart + ->and(Cart::retrieve('cart_checked')->public_id)->toBe('cart_open') + // carts saved before statuses existed are open + ->and(Cart::retrieve('device-2')->public_id)->toBe('cart_legacy') + // asking for closed carts too still finds them + ->and(Cart::retrieve('cart_checked', false)->public_id)->toBe('cart_checked') + ->and(Capsule::connection('mysql')->table('carts')->where('uuid', 'checked_uuid')->value('items'))->toBe('[{"id":"cart_item_1","subtotal":1000}]'); +}); + +test('cart retrieval starts a new cart for the device when only closed carts remain', function () { + createCartLifecycleSchema(); + insertLifecycleCart(['uuid' => 'cleared_uuid', 'public_id' => 'cart_cleared', 'unique_identifier' => 'device-3', 'status' => 'cleared', 'items' => '[{"id":"cart_item_1"}]']); + + $fromDevice = Cart::retrieve('device-3'); + $fromStaleId = Cart::retrieve('cart_cleared'); + + expect($fromDevice->public_id)->not->toBe('cart_cleared') + ->and($fromDevice->unique_identifier)->toBe('device-3') + ->and($fromDevice->status)->toBe(Cart::STATUS_OPEN) + // the stale id finds the device's new open cart instead of an anonymous one + ->and($fromStaleId->uuid)->toBe($fromDevice->uuid) + ->and(Cart::retrieve('cart_unknown')->unique_identifier)->toBeNull() + ->and(Cart::retrieve(null)->status)->toBe(Cart::STATUS_OPEN); +}); + +test('clearing a cart closes it with its items and continues with an open cart for the device', function () { + createCartLifecycleSchema(); + insertLifecycleCart(['uuid' => 'full_uuid', 'public_id' => 'cart_full', 'unique_identifier' => 'device-4', 'status' => 'open', 'items' => '[{"id":"cart_item_1","subtotal":1000}]']); + insertLifecycleCart(['uuid' => 'empty_uuid', 'public_id' => 'cart_empty', 'unique_identifier' => 'device-5', 'status' => 'open']); + insertLifecycleCart(['uuid' => 'done_uuid', 'public_id' => 'cart_done', 'unique_identifier' => 'device-6', 'checkout_uuid' => 'checkout_uuid', 'status' => 'checked_out', 'items' => '[{"id":"cart_item_2"}]']); + + $full = Cart::where('uuid', 'full_uuid')->firstOrFail(); + $next = $full->clear(); + $empty = Cart::where('uuid', 'empty_uuid')->firstOrFail(); + $done = Cart::where('uuid', 'done_uuid')->firstOrFail(); + $after = $done->clear(); + $row = fn ($uuid) => Capsule::connection('mysql')->table('carts')->where('uuid', $uuid)->first(); + + expect($row('full_uuid')->status)->toBe(Cart::STATUS_CLEARED) + ->and($row('full_uuid')->items)->toBe('[{"id":"cart_item_1","subtotal":1000}]') + ->and(json_decode($row('full_uuid')->events)[0]->event)->toBe('cart.cleared') + ->and($next->uuid)->not->toBe('full_uuid') + ->and($next->unique_identifier)->toBe('device-4') + ->and($next->isOpen())->toBeTrue() + // an empty open cart has nothing to keep + ->and($empty->clear()->uuid)->toBe('empty_uuid') + ->and($row('empty_uuid')->status)->toBe(Cart::STATUS_OPEN) + // a checked-out cart is never touched + ->and($row('done_uuid')->status)->toBe(Cart::STATUS_CHECKED_OUT) + ->and($row('done_uuid')->items)->toBe('[{"id":"cart_item_2"}]') + ->and($after->unique_identifier)->toBe('device-6') + ->and($after->uuid)->not->toBe('done_uuid') + ->and($done->isOpen())->toBeFalse(); +}); + diff --git a/server/tests/Unit/Models/CheckoutBehaviorTest.php b/server/tests/Unit/Models/CheckoutBehaviorTest.php index 7c0b86f1..80eb126f 100644 --- a/server/tests/Unit/Models/CheckoutBehaviorTest.php +++ b/server/tests/Unit/Models/CheckoutBehaviorTest.php @@ -56,6 +56,7 @@ public function clear(): void $table->increments('id'); $table->string('uuid')->nullable(); $table->string('checkout_uuid')->nullable(); + $table->string('status')->nullable(); $table->timestamp('expires_at')->nullable(); $table->timestamps(); $table->softDeletes(); @@ -93,5 +94,48 @@ public function clear(): void ->table('carts') ->where('uuid', 'cart_uuid') ->value('checkout_uuid') - )->toBe('checkout_uuid'); + )->toBe('checkout_uuid') + ->and(Illuminate\Database\Capsule\Manager::connection('mysql')->table('carts')->where('uuid', 'cart_uuid')->value('status'))->toBe('checked_out'); +}); + +test('checkout rebuilds the cart as it was priced and charged from its saved copy', function () { + $checkout = new Checkout(); + $checkout->forceFill([ + 'cart_uuid' => 'cart_uuid', + 'cart_state' => [ + 'public_id' => 'cart_public', + 'currency' => 'MNT', + 'unique_identifier' => 'device-store_x', + 'subtotal' => 99999, // appended accessors in the copy are recomputed, not trusted + 'items' => [ + ['id' => 'cart_item_1', 'name' => 'Туршилтын бараа', 'store_id' => 'store_a', 'quantity' => 1, 'subtotal' => 1000], + ['id' => 'cart_item_2', 'name' => 'Бууз', 'store_id' => 'store_a', 'quantity' => 2, 'subtotal' => 24000], + ], + 'events' => [['event' => 'cart.item_added']], + ], + ]); + + $cart = $checkout->cartAtCheckout(); + + expect($cart)->toBeInstanceOf(Fleetbase\Storefront\Models\Cart::class) + ->and($cart->exists)->toBeFalse() + ->and($cart->uuid)->toBe('cart_uuid') + ->and($cart->public_id)->toBe('cart_public') + ->and($cart->getCurrency())->toBe('MNT') + ->and(count($cart->items))->toBe(2) + ->and($cart->items[1]->name)->toBe('Бууз') + ->and($cart->subtotal)->toBe(25000) + ->and(count($cart->events))->toBe(1); +}); + +test('checkout without a saved cart copy has no cart at checkout', function () { + $none = new Checkout(); + $noItems = new Checkout(); + $noItems->forceFill(['cart_state' => ['public_id' => 'cart_public']]); + $badItems = new Checkout(); + $badItems->forceFill(['cart_state' => ['items' => 'not-a-list']]); + + expect($none->cartAtCheckout())->toBeNull() + ->and($noItems->cartAtCheckout())->toBeNull() + ->and($badItems->cartAtCheckout())->toBeNull(); }); diff --git a/server/tests/Unit/Models/StoreBehaviorTest.php b/server/tests/Unit/Models/StoreBehaviorTest.php index b8e473a8..d329f1b9 100644 --- a/server/tests/Unit/Models/StoreBehaviorTest.php +++ b/server/tests/Unit/Models/StoreBehaviorTest.php @@ -423,3 +423,65 @@ function createStoreBehaviorSchema(): void expect(fn () => $missing->getOrderConfig()) ->toThrow(RuntimeException::class, 'No default OrderConfig is configured.'); }); + +test('store rating uses a preloaded review average without querying reviews', function () { + // No reviews table: any aggregate query would throw. + Model::getConnectionResolver()->connection('mysql')->getSchemaBuilder()->dropIfExists('reviews'); + + $decimal = new Store(); + $decimal->setRawAttributes(['uuid' => 'store_uuid', 'reviews_avg_rating' => '4.5000']); + $whole = new Store(); + $whole->setRawAttributes(['uuid' => 'store_uuid', 'reviews_avg_rating' => 4]); + $unrated = new Store(); + $unrated->setRawAttributes(['uuid' => 'store_uuid', 'reviews_avg_rating' => null]); + + expect($decimal->rating)->toBe(4.5) + ->and($whole->rating)->toBe(4) + ->and($unrated->rating)->toBe(0); +}); + +test('store network category uses eager loaded networks', function () { + createStoreBehaviorSchema(); + $connection = Capsule::connection('mysql'); + $connection->table('categories')->insert(['uuid' => 'category_uuid', 'name' => 'Electronics']); + + $member = new Network(); + $member->forceFill(['uuid' => 'network_uuid']); + $member->setRelation('pivot', (object) ['category_uuid' => 'category_uuid']); + $other = new Network(); + $other->forceFill(['uuid' => 'other_network_uuid']); + + $store = new Store(); + $store->forceFill(['uuid' => 'store_uuid']); + $store->setRelation('networks', new Illuminate\Database\Eloquent\Collection([$member])); + + // network_stores is empty, so a match can only come from the loaded relation. + expect($store->getNetworkCategory($member)?->uuid)->toBe('category_uuid') + ->and($store->getNetworkCategory($other))->toBeNull(); +}); + +test('store resolves each network id once', function () { + createStoreBehaviorSchema(); + Store::flushResolvedNetworkIds(); + $connection = Capsule::connection('mysql'); + $connection->table('stores')->insert(['uuid' => 'store_uuid', 'name' => 'Store']); + $connection->table('networks')->insert(['uuid' => 'network_uuid', 'public_id' => 'network_public', 'name' => 'Network']); + $connection->table('categories')->insert(['uuid' => 'category_uuid', 'name' => 'Flowers']); + $connection->table('network_stores')->insert([ + 'network_uuid' => 'network_uuid', + 'store_uuid' => 'store_uuid', + 'category_uuid' => 'category_uuid', + ]); + $store = Store::where('uuid', 'store_uuid')->firstOrFail(); + + expect($store->getNetworkCategoryUsingId('network_public')?->uuid)->toBe('category_uuid'); + + // The id is not looked up again: it still resolves after the public id changes. + $connection->table('networks')->update(['public_id' => 'network_renamed']); + expect($store->getNetworkCategoryUsingId('network_public')?->uuid)->toBe('category_uuid'); + + Store::flushResolvedNetworkIds(); + expect($store->getNetworkCategoryUsingId('network_public'))->toBeNull(); + + Store::flushResolvedNetworkIds(); +}); diff --git a/server/tests/Unit/Promotions/PromotionCheckoutTest.php b/server/tests/Unit/Promotions/PromotionCheckoutTest.php index 85b6c15e..9aa4eff9 100644 --- a/server/tests/Unit/Promotions/PromotionCheckoutTest.php +++ b/server/tests/Unit/Promotions/PromotionCheckoutTest.php @@ -206,6 +206,22 @@ function promotionCart(array $items, ?string $codes = null): Cart ]); }); +test("a checkout's own reservations can be released before it is reserved again", function () { + $promotion = makePromotion(); + promotionDb()->table('promotion_redemptions')->insert([ + ['uuid' => 'mine', 'promotion_uuid' => $promotion->uuid, 'checkout_uuid' => 'checkout_uuid', 'status' => 'reserved', 'created_at' => now()], + ['uuid' => 'used', 'promotion_uuid' => $promotion->uuid, 'checkout_uuid' => 'checkout_uuid', 'status' => 'redeemed', 'created_at' => now()], + ['uuid' => 'theirs', 'promotion_uuid' => $promotion->uuid, 'checkout_uuid' => 'other_checkout_uuid', 'status' => 'reserved', 'created_at' => now()], + ]); + + expect(PromotionRedemptions::releaseFor(promotionCheckout()))->toBe(1) + ->and(promotionDb()->table('promotion_redemptions')->orderBy('uuid')->pluck('status', 'uuid')->all())->toBe([ + 'mine' => 'released', + 'theirs' => 'reserved', + 'used' => 'redeemed', + ]); +}); + test('stale reservations are released by the scheduled command', function () { $promotion = makePromotion(); promotionDb()->table('promotion_redemptions')->insert([ @@ -286,6 +302,7 @@ function createCheckoutPromotionTables(): void $table->string('company_uuid')->nullable(); $table->string('user_uuid')->nullable(); $table->string('checkout_uuid')->nullable(); + $table->string('status')->nullable(); $table->string('customer_id')->nullable(); $table->string('unique_identifier')->nullable(); $table->string('currency')->nullable(); diff --git a/server/tests/Unit/Promotions/PromotionEndpointsTest.php b/server/tests/Unit/Promotions/PromotionEndpointsTest.php index 57556924..34b61d41 100644 --- a/server/tests/Unit/Promotions/PromotionEndpointsTest.php +++ b/server/tests/Unit/Promotions/PromotionEndpointsTest.php @@ -35,6 +35,7 @@ function seedPromotionCart(?string $codes = null): void $table->string('company_uuid')->nullable(); $table->string('user_uuid')->nullable(); $table->string('checkout_uuid')->nullable(); + $table->string('status')->nullable(); $table->string('customer_id')->nullable(); $table->string('unique_identifier')->nullable(); $table->string('currency')->nullable(); diff --git a/server/tests/Unit/Promotions/PublicPromotionDetailsTest.php b/server/tests/Unit/Promotions/PublicPromotionDetailsTest.php new file mode 100644 index 00000000..4dfefbb4 --- /dev/null +++ b/server/tests/Unit/Promotions/PublicPromotionDetailsTest.php @@ -0,0 +1,171 @@ + [1, 2, 3, 4, 5], 'start' => '14:00', 'end' => '17:00']]; + +function publicPromotionRequest(array $input = [], bool $internal = false): Request +{ + $request = Request::create('/', 'GET', $input); + $request->setLaravelSession(new Illuminate\Session\Store('promotion-details', new Illuminate\Session\ArraySessionHandler(120))); + if ($internal) { + $request->setRouteResolver(fn () => new class { + public array $action = []; + + public function uri(): string + { + return 'int/v1/storefront/promotions'; + } + }); + } + app()->instance('request', $request); + + return $request; +} + +beforeEach(function () { + createPromotionSchema(); + seedPromotionStores(); + session(['storefront_key' => 'store_key_a', 'storefront_store' => 'store_a_uuid', 'storefront_network' => null, 'company' => 'company_uuid']); + Carbon::setTestNow(Carbon::parse(PROMO_MONDAY_10AM, 'UTC')); +}); + +afterEach(function () { + Carbon::setTestNow(); +}); + +test('promotion availability distinguishes live, scheduled, ended and inactive promotions', function () { + expect(makePromotion()->availabilityAt())->toBe('live') + ->and(makePromotion(['schedule' => WEEKDAY_AFTERNOONS, 'timezone' => 'UTC'])->availabilityAt())->toBe('scheduled') + ->and(makePromotion(['starts_at' => now()->addDay()])->availabilityAt())->toBe('scheduled') + ->and(makePromotion(['status' => Promotion::STATUS_ENDED])->availabilityAt())->toBe('ended') + ->and(makePromotion(['ends_at' => now()->subMinute()])->availabilityAt())->toBe('ended') + ->and(makePromotion(['status' => Promotion::STATUS_DRAFT])->availabilityAt())->toBe('inactive') + ->and(makePromotion(['status' => Promotion::STATUS_PAUSED])->availabilityAt(now()))->toBe('inactive'); +}); + +test('scheduled promotions report when they next start', function () { + $afternoons = makePromotion(['schedule' => WEEKDAY_AFTERNOONS, 'timezone' => 'UTC']); + $friday = Carbon::parse('2026-10-09 18:00:00', 'UTC'); + $starting = makePromotion(['starts_at' => Carbon::parse('2026-10-20 09:00:00', 'UTC')]); + $overnight = makePromotion([ + 'schedule' => [['days' => [1], 'start' => '22:00', 'end' => '02:00']], + 'timezone' => 'UTC', + 'starts_at' => Carbon::parse('2026-10-05 23:00:00', 'UTC'), + ]); + // Singapore is UTC+8: 14:00 local on Monday is 06:00 UTC, already passed at 10:00 UTC, + // so the next start is Tuesday 06:00 UTC. + $singapore = makePromotion(['schedule' => WEEKDAY_AFTERNOONS, 'timezone' => 'Asia/Singapore']); + $endsFirst = makePromotion(['schedule' => WEEKDAY_AFTERNOONS, 'timezone' => 'UTC', 'ends_at' => Carbon::parse('2026-10-05 12:00:00', 'UTC')]); + $noDays = makePromotion(['schedule' => [['days' => [], 'start' => '14:00', 'end' => '17:00']], 'timezone' => 'UTC']); + + expect(makePromotion()->nextLiveAt())->toBeNull() + ->and($afternoons->nextLiveAt()->toDateTimeString())->toBe('2026-10-05 14:00:00') + ->and($afternoons->nextLiveAt($friday)->toDateTimeString())->toBe('2026-10-12 14:00:00') + ->and($starting->nextLiveAt()->toDateTimeString())->toBe('2026-10-20 09:00:00') + ->and($overnight->nextLiveAt()->toDateTimeString())->toBe('2026-10-05 23:00:00') + ->and($singapore->nextLiveAt()->toDateTimeString())->toBe('2026-10-06 06:00:00') + ->and($endsFirst->nextLiveAt())->toBeNull() + ->and($noDays->nextLiveAt())->toBeNull(); +}); + +test('only one active, unassigned, unexpired, reusable code is shared publicly', function () { + $automatic = makePromotion(); + $coded = makePromotion(['trigger' => Promotion::TRIGGER_CODE]); + makePromotionCode($coded, 'SINGLE', ['usage_limit' => 1]); + makePromotionCode($coded, 'MINE', ['customer_uuid' => 'customer_uuid']); + makePromotionCode($coded, 'OLDCODE', ['expires_at' => now()->subDay()]); + makePromotionCode($coded, 'OFF', ['status' => 'disabled']); + makePromotionCode($coded, 'BLOOM10', ['usage_limit' => 100, 'created_at' => now()->subDay()]); + makePromotionCode($coded, 'BLOOMNEW', ['usage_limit' => null, 'created_at' => now()]); + $batchOnly = makePromotion(['trigger' => Promotion::TRIGGER_CODE]); + makePromotionCode($batchOnly, 'BATCH1', ['usage_limit' => 1]); + + expect($automatic->shareableCode())->toBeNull() + ->and($coded->shareableCode())->toBe('BLOOMNEW') + ->and($coded->load('codes')->shareableCode())->toBe('BLOOMNEW') + ->and($batchOnly->shareableCode())->toBeNull(); +}); + +test('promotions describe their owner and name their targets by public id', function () { + $schema = promotionDb()->getSchemaBuilder(); + $schema->dropIfExists('categories'); + $schema->create('categories', function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('public_id')->nullable(); + $table->timestamp('deleted_at')->nullable(); + }); + promotionDb()->table('categories')->insert(['uuid' => 'category_uuid', 'public_id' => 'category_flowers']); + promotionDb()->table('products')->insert(['uuid' => 'product_1_uuid', 'public_id' => 'product_1', 'store_uuid' => 'store_a_uuid']); + + $storeOwned = makePromotion(['applies_to' => [ + 'products' => ['product_1_uuid', 'product_gone'], + 'exclude_products' => [], + 'stores' => ['store_b'], + 'categories' => ['category_uuid', 42], + 'exclude_categories' => ['category_missing'], + ]]); + $networkOwned = makePromotion(['owner_uuid' => 'network_uuid', 'owner_type' => Network::class]); + $orphaned = makePromotion(['owner_uuid' => 'missing_store']); + + expect($storeOwned->ownerSummary())->toBe(['type' => 'store', 'id' => 'store_a', 'name' => 'Store A', 'logo_url' => null]) + ->and($networkOwned->ownerSummary())->toBe(['type' => 'network', 'id' => 'network_m', 'name' => 'Market', 'logo_url' => null]) + ->and($orphaned->ownerSummary())->toBeNull() + ->and($storeOwned->publicAppliesTo())->toBe([ + 'products' => ['product_1'], + 'stores' => ['store_b'], + 'categories' => ['category_flowers'], + 'exclude_categories' => [], + ]) + ->and($networkOwned->publicAppliesTo())->toBe([]); +}); + +test('public promotion listings can include scheduled promotions and details open for scheduled and ended ones', function () { + $live = makePromotion(['name' => 'Live']); + $scheduled = makePromotion(['name' => 'Afternoons', 'schedule' => WEEKDAY_AFTERNOONS, 'timezone' => 'UTC']); + $ended = makePromotion(['name' => 'Ended', 'status' => Promotion::STATUS_ENDED]); + $paused = makePromotion(['name' => 'Paused', 'status' => Promotion::STATUS_PAUSED]); + $draft = makePromotion(['name' => 'Draft', 'status' => Promotion::STATUS_DRAFT]); + $controller = new PromotionController(); + + $default = collect($controller->query(publicPromotionRequest())->resolve())->pluck('name')->all(); + $withLater = collect($controller->query(publicPromotionRequest(['include' => 'scheduled']))->resolve()); + + expect($default)->toBe(['Live']) + ->and($withLater->pluck('name')->all())->toBe(['Live', 'Afternoons']) + ->and($withLater->firstWhere('name', 'Afternoons')['availability'])->toBe('scheduled') + ->and($withLater->firstWhere('name', 'Afternoons')['next_starts_at']->toDateTimeString())->toBe('2026-10-05 14:00:00') + ->and($controller->find($scheduled->public_id)->resolve()['availability'])->toBe('scheduled') + ->and($controller->find($ended->public_id)->resolve()['availability'])->toBe('ended') + ->and($controller->find($live->public_id)->resolve()['availability'])->toBe('live') + ->and($controller->find($paused->public_id)->getStatusCode())->toBe(404) + ->and($controller->find($draft->public_id)->getStatusCode())->toBe(404); +}); + +test('public promotion resources add owner and shareable code, internal ones keep raw targets', function () { + $promotion = makePromotion(['trigger' => Promotion::TRIGGER_CODE, 'applies_to' => ['stores' => ['store_a_uuid']]]); + makePromotionCode($promotion, 'SHARE', ['usage_limit' => null]); + + $public = (new PromotionResource($promotion))->resolve(publicPromotionRequest()); + $internal = (new PromotionResource($promotion))->resolve(publicPromotionRequest([], true)); + + expect($public['owner'])->toBe(['type' => 'store', 'id' => 'store_a', 'name' => 'Store A', 'logo_url' => null]) + ->and($public['code'])->toBe('SHARE') + ->and($public['availability'])->toBe('live') + ->and($public['next_starts_at'])->toBeNull() + ->and($public['applies_to'])->toBe(['stores' => ['store_a']]) + ->and($internal)->not->toHaveKey('owner') + ->and($internal)->not->toHaveKey('code') + ->and($internal['applies_to'])->toBe(['stores' => ['store_a_uuid']]); +}); diff --git a/server/tests/Unit/Providers/StorefrontServiceProviderTest.php b/server/tests/Unit/Providers/StorefrontServiceProviderTest.php index d71c6aac..485d25c6 100644 --- a/server/tests/Unit/Providers/StorefrontServiceProviderTest.php +++ b/server/tests/Unit/Providers/StorefrontServiceProviderTest.php @@ -100,6 +100,11 @@ protected function mergeConfigFrom($path, $key) { $this->calls[] = $key; } + + public function registerStorefrontSocketChannels(): void + { + $this->calls[] = 'socket-channels'; + } }; $provider->boot(); @@ -119,5 +124,17 @@ protected function mergeConfigFrom($path, $key) 'database.connections', 'storefront', 'storefront.api', + 'socket-channels', ]); }); + +test('storefront provider registers its socket channel resolvers with the core registry', function () { + $app = new Fleetbase\TestSupport\ApplicationContainer(); + $registry = new Fleetbase\Support\SocketCluster\SocketChannelRegistry(); + $app->instance(Fleetbase\Support\SocketCluster\SocketChannelRegistry::class, $registry); + + (new StorefrontServiceProvider($app))->registerStorefrontSocketChannels(); + + expect($registry->resolve('storefront'))->toBeInstanceOf(Closure::class) + ->and($registry->resolve('checkout'))->toBeInstanceOf(Closure::class); +}); diff --git a/server/tests/Unit/Routes/StorefrontRoutesTest.php b/server/tests/Unit/Routes/StorefrontRoutesTest.php index 8b8f4452..80cf5f9b 100644 --- a/server/tests/Unit/Routes/StorefrontRoutesTest.php +++ b/server/tests/Unit/Routes/StorefrontRoutesTest.php @@ -92,9 +92,17 @@ private function record(string $method, string $uri, mixed $action): self ['GET', 'about', 'StoreController@about'], ['POST', '/', 'ProductController@create'], ['POST', 'receipt', 'OrderController@getReceipt'], + ['GET', '{id}/activity-flow', 'OrderController@getActivityFlow'], + ['POST', 'socket-token', 'CustomerController@socketToken'], ['POST', 'send-push-notification', 'ActionController@sendPushNotification'], ['FLEETBASE', 'orders', null], ['FLEETBASE', 'products', null], ['GET', '/', 'MetricsController@all'], - )->and(count($router->routes))->toBeGreaterThan(50); + ['GET', '{id}/chat', 'OrderChatController@show'], + ['GET', '{id}/chat/messages', 'OrderChatController@messages'], + ['POST', '{id}/chat/messages', 'OrderChatController@send'], + ['POST', '{id}/chat/read', 'OrderChatController@read'], + ['GET', 'eligibility', 'ReviewController@eligibility'], + ['DELETE', '{id}', 'ReviewController@delete'], + )->and($router->routes)->not->toContain(['DELETE', '{id}', 'ReviewController@find'])->and(count($router->routes))->toBeGreaterThan(50); }); diff --git a/server/tests/Unit/Rules/RuleContractsTest.php b/server/tests/Unit/Rules/RuleContractsTest.php index d9e3dec2..3bc34a00 100644 --- a/server/tests/Unit/Rules/RuleContractsTest.php +++ b/server/tests/Unit/Rules/RuleContractsTest.php @@ -41,6 +41,7 @@ $schema->dropIfExists('carts'); $schema->create('carts', function (Illuminate\Database\Schema\Blueprint $table) { $table->increments('id'); + $table->string('status')->nullable(); $table->string('public_id')->nullable(); $table->string('unique_identifier')->nullable(); $table->timestamp('expires_at')->nullable(); diff --git a/server/tests/Unit/Support/OrderActivityFlowTest.php b/server/tests/Unit/Support/OrderActivityFlowTest.php new file mode 100644 index 00000000..5010b109 --- /dev/null +++ b/server/tests/Unit/Support/OrderActivityFlowTest.php @@ -0,0 +1,116 @@ + 'if', 'conditions' => [['field' => 'meta.is_pickup', 'operator' => 'equal', 'value' => 'true']]]]; + + return [ + 'created' => ['code' => 'created', 'status' => 'Order Created', 'activities' => ['started', 'canceled']], + 'started' => ['code' => 'started', 'status' => 'Order Started', 'activities' => ['canceled', 'preparing']], + 'preparing' => ['code' => 'preparing', 'status' => 'Order is being prepared', 'details' => '{storefront.name} is preparing your order {unknown}', 'activities' => ['driver_enroute_to_store', 'pickup_ready']], + 'driver_enroute_to_store' => ['code' => 'driver_enroute_to_store', 'status' => 'Driver en-route', 'activities' => ['driver_picked_up']], + 'driver_picked_up' => ['code' => 'driver_picked_up', 'status' => 'Driver picked up', 'activities' => ['driver_enroute']], + 'driver_enroute' => ['code' => 'driver_enroute', 'status' => 'Driver en-route to you', 'activities' => ['completed']], + 'completed' => ['code' => 'completed', 'status' => 'Order completed', 'complete' => true], + 'pickup_ready' => ['code' => 'pickup_ready', 'status' => 'Ready for pickup', 'logic' => $pickupOnly, 'activities' => ['picked_up']], + 'picked_up' => ['code' => 'picked_up', 'status' => 'Picked up', 'complete' => 'true'], + 'canceled' => ['code' => 'canceled', 'status' => 'Order canceled', 'events' => ['order.canceled']], + 'not-an-activity' => 'ignored', + ]; +} + +function flowCodes(array $result): array +{ + return array_map(fn ($step) => $step['code'] . ':' . $step['state'], $result['steps']); +} + +test('a delivery order follows the unconditional branch and stops at the completing activity', function () { + $history = [ + ['code' => 'CREATED', 'label' => 'Order Created', 'at' => '2026-10-08T10:00:00+00:00'], + ['code' => 'started', 'label' => 'Order Started', 'at' => '2026-10-08T10:01:00+00:00'], + ['code' => 'started', 'label' => 'Order Started', 'at' => '2026-10-08T10:02:00+00:00'], + ['code' => '', 'label' => 'Blank'], + ['code' => 'preparing', 'label' => 'Order is being prepared', 'at' => '2026-10-08T10:03:00+00:00'], + ]; + // Only pickup_ready has conditions, and a delivery order fails them. + $result = OrderActivityFlow::resolve(storefrontFlow(), 'preparing', $history, fn ($activity) => empty($activity['logic']), null, ['storefront' => ['name' => 'Orchard Grocers']]); + + expect(flowCodes($result))->toBe([ + 'created:done', 'started:done', 'preparing:current', + 'driver_enroute_to_store:upcoming', 'driver_picked_up:upcoming', 'driver_enroute:upcoming', 'completed:upcoming', + ]) + ->and($result['status'])->toBe('preparing') + ->and($result['canceled'])->toBeFalse() + ->and($result['completed'])->toBeFalse() + ->and($result['steps'][0]['reached_at'])->toBe('2026-10-08T10:00:00+00:00') + ->and($result['steps'][1]['reached_at'])->toBe('2026-10-08T10:01:00+00:00') + ->and($result['steps'][2]['details'])->toBe('Orchard Grocers is preparing your order') + ->and($result['steps'][3]['reached_at'])->toBeNull() + ->and($result['steps'][6]['complete'])->toBeTrue(); +}); + +test('a pickup order takes the branch whose conditions it meets', function () { + $result = OrderActivityFlow::resolve(storefrontFlow(), 'preparing', [], fn () => true, '2026-10-08T09:59:00+00:00'); + + expect(flowCodes($result))->toBe(['created:done', 'preparing:current', 'pickup_ready:upcoming', 'picked_up:upcoming']) + ->and($result['steps'][0]['reached_at'])->toBe('2026-10-08T09:59:00+00:00') + ->and($result['steps'][1]['details'])->toBe('is preparing your order'); +}); + +test('a custom linear flow shows its own steps and labels', function () { + $flow = [ + 'created' => ['status' => 'Created', 'activities' => ['dispatched']], + 'dispatched' => ['status' => 'Dispatched', 'activities' => ['started']], + 'started' => ['status' => 'Started', 'activities' => ['enroute']], + 'enroute' => ['status' => 'Enroute', 'activities' => ['completed']], + 'completed' => ['status' => 'Completed', 'complete' => true], + ]; + $result = OrderActivityFlow::resolve($flow, 'started', [['code' => 'created', 'label' => 'Created'], ['code' => 'dispatched', 'label' => 'Dispatched']], fn () => true); + + expect(flowCodes($result))->toBe(['created:done', 'dispatched:done', 'started:current', 'enroute:upcoming', 'completed:upcoming']) + ->and(array_column($result['steps'], 'label'))->toBe(['Created', 'Dispatched', 'Started', 'Enroute', 'Completed']); +}); + +test('completed and canceled orders project nothing further', function () { + $completed = OrderActivityFlow::resolve(storefrontFlow(), 'completed', [['code' => 'created'], ['code' => 'completed']], fn () => true); + $canceled = OrderActivityFlow::resolve(storefrontFlow(), 'canceled', [['code' => 'created'], ['code' => 'started']], fn () => true); + + expect(flowCodes($completed))->toBe(['created:done', 'completed:done']) + ->and($completed['completed'])->toBeTrue() + ->and(flowCodes($canceled))->toBe(['created:done', 'started:done', 'canceled:current']) + ->and($canceled['canceled'])->toBeTrue() + ->and($canceled['completed'])->toBeFalse(); +}); + +test('a status the flow does not know is still shown, with its tracking label or a readable code', function () { + $tracked = OrderActivityFlow::resolve(storefrontFlow(), 'at_warehouse', [['code' => 'created'], ['code' => 'at_warehouse', 'label' => 'At Warehouse']], fn () => true); + $untracked = OrderActivityFlow::resolve([], 'at_warehouse', [], fn () => true); + $empty = OrderActivityFlow::resolve([], null, [], fn () => true); + $historyOnly = OrderActivityFlow::resolve([], null, [['code' => 'created', 'label' => 'Created']], fn () => true); + + expect(flowCodes($tracked))->toBe(['created:done', 'at_warehouse:current']) + ->and($tracked['steps'][1]['label'])->toBe('At Warehouse') + ->and($untracked['steps'][0]['label'])->toBe('At warehouse') + ->and($empty)->toBe(['status' => null, 'canceled' => false, 'completed' => false, 'steps' => []]) + ->and(flowCodes($historyOnly))->toBe(['created:current']); +}); + +test('projection skips cancellations, visited activities and failing branches, and stops at a limit', function () { + $loop = [ + 'created' => ['activities' => ['canceled', 'missing', 'created', 'step_0']], + 'canceled' => ['activities' => []], + 'cancel_event' => ['events' => ['order.canceled']], + ]; + for ($i = 0; $i < 30; $i++) { + $loop["step_$i"] = ['activities' => ['cancel_event', 'step_' . ($i + 1)]]; + } + $result = OrderActivityFlow::resolve($loop, 'created', [], fn () => true); + $blocked = OrderActivityFlow::resolve(['created' => ['activities' => ['next']], 'next' => []], 'created', [], fn () => false); + + expect($result['steps'])->toHaveLength(1 + OrderActivityFlow::MAX_PROJECTED) + ->and($result['steps'][1]['code'])->toBe('step_0') + ->and(flowCodes($blocked))->toBe(['created:current']); +}); diff --git a/server/tests/Unit/Support/ReviewEligibilityTest.php b/server/tests/Unit/Support/ReviewEligibilityTest.php new file mode 100644 index 00000000..7f365af4 --- /dev/null +++ b/server/tests/Unit/Support/ReviewEligibilityTest.php @@ -0,0 +1,289 @@ +connection('mysql')->getSchemaBuilder(); + foreach (['reviews', 'orders', 'entities', 'products', 'stores', 'contacts', 'personal_access_tokens'] as $table) { + $schema->dropIfExists($table); + } + $schema->create('reviews', function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('public_id')->nullable(); + $table->string('created_by_uuid')->nullable(); + $table->string('customer_uuid')->nullable(); + $table->string('order_uuid')->nullable(); + $table->string('subject_uuid')->nullable(); + $table->string('subject_type')->nullable(); + $table->integer('rating')->nullable(); + $table->text('content')->nullable(); + $table->boolean('rejected')->default(false); + $table->timestamps(); + $table->timestamp('deleted_at')->nullable(); + }); + $schema->create('orders', function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('public_id')->nullable(); + $table->string('customer_uuid')->nullable(); + $table->string('payload_uuid')->nullable(); + $table->string('status')->nullable(); + $table->text('meta')->nullable(); + $table->timestamp('created_at')->nullable(); + $table->timestamp('updated_at')->nullable(); + $table->timestamp('deleted_at')->nullable(); + }); + $schema->create('entities', function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('payload_uuid')->nullable(); + $table->string('internal_id')->nullable(); + $table->timestamp('deleted_at')->nullable(); + }); + $schema->create('products', function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('public_id')->nullable(); + $table->string('store_uuid')->nullable(); + $table->timestamp('deleted_at')->nullable(); + }); + $schema->create('stores', function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('public_id')->nullable(); + $table->string('company_uuid')->nullable(); + $table->string('name')->nullable(); + $table->text('options')->nullable(); + $table->text('translations')->nullable(); + $table->text('tags')->nullable(); + $table->timestamp('deleted_at')->nullable(); + }); + $schema->create('contacts', function ($table) { + $table->increments('id'); + $table->string('uuid'); + $table->string('public_id')->nullable(); + $table->string('user_uuid')->nullable(); + $table->string('type')->nullable(); + $table->timestamp('deleted_at')->nullable(); + }); + $schema->create('personal_access_tokens', function ($table) { + $table->increments('id'); + $table->string('tokenable_type')->nullable(); + $table->integer('tokenable_id')->nullable(); + $table->string('name'); + $table->string('token'); + $table->text('abilities')->nullable(); + $table->timestamp('last_used_at')->nullable(); + $table->timestamp('expires_at')->nullable(); + $table->timestamps(); + }); + + $connection = Model::getConnectionResolver()->connection('mysql'); + $connection->table('stores')->insert(['uuid' => 'store_uuid', 'public_id' => 'store_abcdefgh', 'company_uuid' => 'company_uuid', 'name' => 'Bloom']); + $connection->table('products')->insert(['uuid' => 'product_uuid', 'public_id' => 'product_abcdefgh', 'store_uuid' => 'store_uuid']); + $connection->table('contacts')->insert(['uuid' => ELIGIBILITY_CUSTOMER_UUID, 'public_id' => 'contact_eligible', 'user_uuid' => 'user_uuid', 'type' => 'customer']); + $connection->table('orders')->insert([ + // Completed orders for the store, newest last so ordering is exercised. + ['uuid' => 'store_order_old', 'public_id' => 'order_old', 'customer_uuid' => ELIGIBILITY_CUSTOMER_UUID, 'status' => 'completed', 'payload_uuid' => 'payload_old', 'meta' => json_encode(['storefront_id' => 'store_abcdefgh']), 'created_at' => now()->subDays(5)], + ['uuid' => 'store_order_new', 'public_id' => 'order_new', 'customer_uuid' => ELIGIBILITY_CUSTOMER_UUID, 'status' => 'completed', 'payload_uuid' => 'payload_new', 'meta' => json_encode(['storefront_id' => 'store_abcdefgh']), 'created_at' => now()->subDay()], + // Not eligible: still in progress, another store, another customer. + ['uuid' => 'store_order_active', 'public_id' => 'order_active', 'customer_uuid' => ELIGIBILITY_CUSTOMER_UUID, 'status' => 'dispatched', 'payload_uuid' => 'payload_active', 'meta' => json_encode(['storefront_id' => 'store_abcdefgh']), 'created_at' => now()], + ['uuid' => 'other_store_order', 'public_id' => 'order_other_store', 'customer_uuid' => ELIGIBILITY_CUSTOMER_UUID, 'status' => 'completed', 'payload_uuid' => null, 'meta' => json_encode(['storefront_id' => 'store_other']), 'created_at' => now()], + ['uuid' => 'other_customer_order', 'public_id' => 'order_other_customer', 'customer_uuid' => 'someone_else', 'status' => 'completed', 'payload_uuid' => null, 'meta' => json_encode(['storefront_id' => 'store_abcdefgh']), 'created_at' => now()], + ]); + // Only the newest store order contains the product. + $connection->table('entities')->insert([ + ['uuid' => 'entity_new', 'payload_uuid' => 'payload_new', 'internal_id' => 'product_abcdefgh'], + ['uuid' => 'entity_active', 'payload_uuid' => 'payload_active', 'internal_id' => 'product_abcdefgh'], + ['uuid' => 'entity_orphan', 'payload_uuid' => null, 'internal_id' => 'product_abcdefgh'], + ]); +} + +function eligibilityCustomer(): Contact +{ + return Contact::where('uuid', ELIGIBILITY_CUSTOMER_UUID)->first(); +} + +function bindEligibilityRequest(?string $customerToken = null, array $query = []): Request +{ + $request = Request::create('/reviews/eligibility', 'GET', $query); + if ($customerToken) { + $request->headers->set('Customer-Token', $customerToken); + } + $request->setLaravelSession(new Illuminate\Session\Store('review-eligibility', new Illuminate\Session\ArraySessionHandler(120))); + app()->instance('request', $request); + session(['company' => 'company_uuid', 'storefront_key' => 'store_key', 'storefront_store' => 'store_uuid', 'storefront_network' => null]); + + return $request; +} + +function issueEligibilityToken(): string +{ + Model::getConnectionResolver()->connection('mysql')->table('personal_access_tokens')->insert([ + 'name' => ELIGIBILITY_CUSTOMER_UUID, + 'token' => hash('sha256', 'eligibility-secret'), + 'abilities' => '["*"]', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + return 'eligibility-secret'; +} + +test('review eligibility requires a signed-in customer and a store or product subject', function () { + createReviewEligibilitySchema(); + $store = Store::where('uuid', 'store_uuid')->first(); + + $signedOut = ReviewEligibility::check(null, $store); + $unsupported = ReviewEligibility::check(eligibilityCustomer(), new stdClass()); + + expect($signedOut->allowed)->toBeFalse() + ->and($signedOut->toArray())->toBe([ + 'can_review' => false, + 'reason' => 'sign_in_required', + 'message' => 'Sign in to write a review.', + 'order' => null, + 'review' => null, + ]) + ->and($unsupported->reason)->toBe('unsupported_subject') + ->and($unsupported->message())->toBe('Only stores and products can be reviewed.') + ->and(ReviewEligibility::completedOrdersFor(eligibilityCustomer(), new stdClass()))->toBeNull(); +}); + +test('store reviews use the newest completed order from that store that has not been reviewed', function () { + createReviewEligibilitySchema(); + $store = Store::where('uuid', 'store_uuid')->first(); + $customer = eligibilityCustomer(); + + $first = ReviewEligibility::check($customer, $store); + Review::create(['customer_uuid' => $customer->uuid, 'subject_uuid' => $store->uuid, 'order_uuid' => 'store_order_new', 'rating' => 5, 'content' => 'Great']); + $second = ReviewEligibility::check($customer, $store); + Review::create(['customer_uuid' => $customer->uuid, 'subject_uuid' => $store->uuid, 'order_uuid' => 'store_order_old', 'rating' => 4, 'content' => 'Good']); + $done = ReviewEligibility::check($customer, $store); + + expect($first->allowed)->toBeTrue() + ->and($first->reason)->toBeNull() + ->and($first->message())->toBeNull() + ->and($first->order->uuid)->toBe('store_order_new') + ->and($second->order->uuid)->toBe('store_order_old') + ->and($done->allowed)->toBeFalse() + ->and($done->reason)->toBe('already_reviewed') + ->and($done->order->public_id)->toBe('order_new') + ->and($done->review->order_uuid)->toBe('store_order_new') + ->and($done->toArray()['order'])->toBe('order_new'); +}); + +test('product reviews need a completed order containing the product and can target one order', function () { + createReviewEligibilitySchema(); + $product = Product::where('uuid', 'product_uuid')->first(); + $store = Store::where('uuid', 'store_uuid')->first(); + $customer = eligibilityCustomer(); + + $product_ = ReviewEligibility::check($customer, $product); + $byPublic = ReviewEligibility::check($customer, $store, 'order_old'); + $byUuid = ReviewEligibility::check($customer, $store, 'store_order_old'); + $notMine = ReviewEligibility::check($customer, $store, 'order_other_customer'); + $missing = ReviewEligibility::check($customer, $product, 'order_old'); + + expect($product_->allowed)->toBeTrue() + ->and($product_->order->uuid)->toBe('store_order_new') + ->and($byPublic->order->uuid)->toBe('store_order_old') + ->and($byUuid->order->uuid)->toBe('store_order_old') + ->and($notMine->reason)->toBe('no_completed_order') + ->and($missing->allowed)->toBeFalse() + ->and($missing->message())->toBe('You can review this after an order with it is completed.'); +}); + +test('review eligibility endpoint validates the subject and reports the signed-in customer state', function () { + createReviewEligibilitySchema(); + $controller = new ReviewController(); + + bindEligibilityRequest(); + $missing = $controller->eligibility(request()); + bindEligibilityRequest(null, ['subject' => 'store_missing']); + $invalid = $controller->eligibility(request()); + bindEligibilityRequest(null, ['subject' => 'store_abcdefgh']); + $signedOut = $controller->eligibility(request()); + $token = issueEligibilityToken(); + bindEligibilityRequest($token, ['subject' => 'store_abcdefgh']); + $eligible = $controller->eligibility(request()); + bindEligibilityRequest($token, ['subject' => 'product_abcdefgh', 'order' => 'order_old']); + $wrongOrder = $controller->eligibility(request()); + + expect($missing->getData(true))->toBe(['error' => 'A subject is required.']) + ->and($invalid->getData(true))->toBe(['error' => 'Invalid subject for review']) + ->and($signedOut->getData(true)['reason'])->toBe('sign_in_required') + ->and($eligible->getData(true))->toBe([ + 'can_review' => true, + 'reason' => null, + 'message' => null, + 'order' => 'order_new', + 'review' => null, + ]) + ->and($wrongOrder->getData(true)['reason'])->toBe('no_completed_order'); +}); + +test('creating a review without a completed order is refused with the reason', function () { + createReviewEligibilitySchema(); + Model::getConnectionResolver()->connection('mysql')->table('orders')->update(['status' => 'canceled']); + bindEligibilityRequest(issueEligibilityToken()); + + $response = (new ReviewController())->create(CreateReviewRequest::create('/reviews', 'POST', [ + 'subject' => 'store_abcdefgh', + 'rating' => 5, + 'content' => 'Never received it', + ])); + + expect($response->getStatusCode())->toBe(403) + ->and($response->getData(true))->toBe([ + 'error' => 'You can review this after an order with it is completed.', + 'reason' => 'no_completed_order', + ]) + ->and(Review::query()->count())->toBe(0); +}); + +test('review resources mark the viewer own verified product reviews and keep internal ids internal', function () { + createReviewEligibilitySchema(); + $product = Product::where('uuid', 'product_uuid')->first(); + $mine = new Review(['customer_uuid' => ELIGIBILITY_CUSTOMER_UUID, 'subject_uuid' => 'product_uuid', 'rating' => 4, 'content' => 'Nice']); + $mine->setRelation('subject', $product)->setRelation('customer', null)->setRelation('photos', collect()); + $other = new Review(['customer_uuid' => null, 'order_uuid' => null, 'rating' => 2, 'content' => 'Meh']); + $other->setRelation('subject', null)->setRelation('customer', null)->setRelation('photos', collect()); + + bindEligibilityRequest(issueEligibilityToken()); + $publicMine = (new ReviewResource($mine))->toArray(request()); + $publicOther = (new ReviewResource($other))->toArray(request()); + // The viewer is resolved once per request. + Model::getConnectionResolver()->connection('mysql')->table('personal_access_tokens')->delete(); + $cached = ReviewResource::viewerCustomerUuid(); + + expect($publicMine['subject_id'])->toBe('product_abcdefgh') + ->and($publicMine['subject_type'])->toBe('product') + ->and($publicMine['verified'])->toBeFalse() + ->and($publicMine['is_mine'])->toBeTrue() + ->and($publicOther['subject_id'])->toBeNull() + ->and($publicOther['is_mine'])->toBeFalse() + ->and($cached)->toBe(ELIGIBILITY_CUSTOMER_UUID); +}); + +test('reviews belong to the completed order they were written for', function () { + $relation = (new Review())->order(); + + expect($relation)->toBeInstanceOf(Illuminate\Database\Eloquent\Relations\BelongsTo::class) + ->and($relation->getRelated())->toBeInstanceOf(Fleetbase\FleetOps\Models\Order::class) + ->and($relation->getForeignKeyName())->toBe('order_uuid') + ->and($relation->getOwnerKeyName())->toBe('uuid'); +}); diff --git a/server/tests/Unit/Support/StorefrontSocketTest.php b/server/tests/Unit/Support/StorefrontSocketTest.php new file mode 100644 index 00000000..e0e606a8 --- /dev/null +++ b/server/tests/Unit/Support/StorefrontSocketTest.php @@ -0,0 +1,355 @@ + $enabled ? 'storefront-socket-test-key-0123456789abcdef' : null]); +} + +function storefrontSocketSchema(): void +{ + $connection = Model::getConnectionResolver()->connection('mysql'); + $schema = $connection->getSchemaBuilder(); + + foreach (['companies', 'contacts', 'personal_access_tokens', 'stores', 'networks', 'checkouts'] as $table) { + $schema->dropIfExists($table); + } + + $schema->create('companies', function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('public_id')->nullable(); + $table->timestamps(); + $table->timestamp('deleted_at')->nullable(); + }); + $schema->create('contacts', function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('public_id')->nullable(); + $table->string('company_uuid')->nullable(); + $table->string('user_uuid')->nullable(); + $table->string('type')->nullable(); + $table->string('name')->nullable(); + $table->text('meta')->nullable(); + $table->timestamps(); + $table->timestamp('deleted_at')->nullable(); + }); + $schema->create('personal_access_tokens', function ($table) { + $table->increments('id'); + $table->string('tokenable_type')->nullable(); + $table->string('tokenable_id')->nullable(); + $table->string('name'); + $table->string('token', 64)->unique(); + $table->text('abilities')->nullable(); + $table->timestamp('last_used_at')->nullable(); + $table->timestamp('expires_at')->nullable(); + $table->timestamps(); + }); + foreach (['stores', 'networks'] as $storefrontTable) { + $schema->create($storefrontTable, function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('public_id')->nullable(); + $table->string('company_uuid')->nullable(); + $table->string('backdrop_uuid')->nullable(); + $table->string('logo_uuid')->nullable(); + $table->string('order_config_uuid')->nullable(); + $table->string('key')->nullable(); + $table->string('name')->nullable(); + $table->text('description')->nullable(); + $table->text('translations')->nullable(); + $table->string('website')->nullable(); + $table->string('facebook')->nullable(); + $table->string('instagram')->nullable(); + $table->string('twitter')->nullable(); + $table->string('email')->nullable(); + $table->string('phone')->nullable(); + $table->text('tags')->nullable(); + $table->string('currency')->nullable(); + $table->string('timezone')->nullable(); + $table->string('pod_method')->nullable(); + $table->text('options')->nullable(); + $table->text('alertable')->nullable(); + $table->timestamps(); + $table->timestamp('deleted_at')->nullable(); + }); + } + $schema->create('checkouts', function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('public_id')->nullable(); + $table->string('company_uuid')->nullable(); + $table->string('store_uuid')->nullable(); + $table->string('network_uuid')->nullable(); + $table->string('owner_uuid')->nullable(); + $table->string('owner_type')->nullable(); + $table->text('options')->nullable(); + $table->string('token')->nullable(); + $table->string('order_uuid')->nullable(); + $table->boolean('captured')->default(false); + $table->timestamps(); + $table->timestamp('deleted_at')->nullable(); + }); + + $connection->table('companies')->insert([ + ['uuid' => 'company-a', 'public_id' => 'company_aaaaaaa'], + ['uuid' => 'company-b', 'public_id' => 'company_bbbbbbb'], + ]); + $connection->table('stores')->insert([ + ['uuid' => 'store-a', 'public_id' => 'store_aaaaaaa', 'company_uuid' => 'company-a', 'key' => 'store_key_a'], + ['uuid' => 'store-a2', 'public_id' => 'store_aaaaaa2', 'company_uuid' => 'company-a', 'key' => 'store_key_a2'], + ['uuid' => 'store-b', 'public_id' => 'store_bbbbbbb', 'company_uuid' => 'company-b', 'key' => 'store_key_b'], + ['uuid' => 'store-orphan', 'public_id' => 'store_orphan1', 'company_uuid' => null, 'key' => 'store_key_orphan'], + ]); + $connection->table('networks')->insert([ + ['uuid' => 'network-a', 'public_id' => 'network_aaaaaaa', 'company_uuid' => 'company-a', 'key' => 'network_key_a'], + ]); + $connection->table('contacts')->insert([ + ['uuid' => 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', 'public_id' => 'contact_aaaaaaa', 'company_uuid' => 'company-a', 'user_uuid' => 'user-a', 'type' => 'customer'], + ['uuid' => 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', 'public_id' => 'contact_bbbbbbb', 'company_uuid' => 'company-b', 'user_uuid' => null, 'type' => 'customer'], + ]); + $connection->table('checkouts')->insert([ + ['uuid' => 'checkout-a', 'public_id' => 'chkt_aaaaaaa', 'company_uuid' => 'company-a', 'store_uuid' => 'store-a', 'network_uuid' => null, 'owner_uuid' => 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', 'token' => 'checkout_a'], + ['uuid' => 'checkout-guest', 'public_id' => 'chkt_guest01', 'company_uuid' => 'company-a', 'store_uuid' => null, 'network_uuid' => 'network-a', 'owner_uuid' => null, 'token' => 'checkout_g'], + ['uuid' => 'checkout-b', 'public_id' => 'chkt_bbbbbbb', 'company_uuid' => 'company-b', 'store_uuid' => 'store-b', 'network_uuid' => null, 'owner_uuid' => 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', 'token' => 'checkout_b'], + ['uuid' => 'checkout-orphan', 'public_id' => 'chkt_orphan1', 'company_uuid' => null, 'store_uuid' => null, 'network_uuid' => null, 'owner_uuid' => 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', 'token' => 'checkout_o'], + ]); + + foreach (['aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' => 'customer-secret-a', 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' => 'customer-secret-b'] as $contact => $secret) { + $connection->table('personal_access_tokens')->insert([ + 'name' => $contact, + 'token' => hash('sha256', $secret), + 'abilities' => '["*"]', + 'created_at' => now(), + 'updated_at' => now(), + ]); + } +} + +function storefrontSocketTokenRequest(?string $storefrontKey, ?string $customerToken): Request +{ + $request = Request::create('/storefront/v1/customers/socket-token', 'POST'); + $request->setLaravelSession(new SessionStore('storefront-socket-test', new ArraySessionHandler(120))); + if ($customerToken) { + $request->headers->set('Customer-Token', $customerToken); + } + app()->instance('request', $request); + session(['storefront_key' => $storefrontKey]); + + return $request; +} + +function storefrontSocketPrincipal(string $kind, array $claims = []): SocketPrincipal +{ + return SocketPrincipal::fromClaims(array_merge(['kind' => $kind, 'sub' => $kind . '-subject', 'cid' => 'company-a'], $claims)); +} + +beforeEach(function () { + storefrontSocketSchema(); + storefrontSocketEnable(false); +}); + +afterEach(function () { + storefrontSocketEnable(false); + session(['storefront_key' => null]); +}); + +test('customer socket token is not found while socket auth is disabled', function () { + $response = (new CustomerController())->socketToken(storefrontSocketTokenRequest('store_key_a', 'customer-secret-a')); + + expect($response->getStatusCode())->toBe(404) + ->and($response->getData(true))->toBe(['error' => 'Not found.']); +}); + +test('customer socket token requires an authenticated customer of the storefront company', function () { + storefrontSocketEnable(); + $controller = new CustomerController(); + + $noCustomer = $controller->socketToken(storefrontSocketTokenRequest('store_key_a', null)); + $unknownCustomer = $controller->socketToken(storefrontSocketTokenRequest('store_key_a', 'not-a-real-token')); + $noStorefront = $controller->socketToken(storefrontSocketTokenRequest(null, 'customer-secret-a')); + $otherCompany = $controller->socketToken(storefrontSocketTokenRequest('store_key_b', 'customer-secret-a')); + + foreach ([$noCustomer, $unknownCustomer, $noStorefront, $otherCompany] as $response) { + expect($response->getStatusCode())->toBe(401) + ->and($response->getData(true))->toBe(['error' => 'Not authorized to create a socket token for customer.']); + } +}); + +test('customer socket token mints a customer principal scoped to the store', function () { + storefrontSocketEnable(); + + $response = (new CustomerController())->socketToken(storefrontSocketTokenRequest('store_key_a', 'customer-secret-a')); + $body = $response->getData(true); + $claims = SocketToken::verify($body['token']); + + expect($response->getStatusCode())->toBe(200) + ->and(array_keys($body))->toBe(['token', 'expires_in', 'expires_at']) + ->and($body['expires_in'])->toBe(900) + ->and($claims)->toBeInstanceOf(SocketPrincipal::class) + ->and($claims->kind)->toBe('customer') + ->and($claims->sub)->toBe('aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa') + // The user uuid lets the customer join chats they take part in (core authorizes + // chat channels by participant user). + ->and($claims->ids)->toBe(['aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', 'contact_aaaaaaa', 'user-a']) + ->and($claims->cid)->toBe('company-a') + ->and($claims->cpid)->toBe('company_aaaaaaa') + ->and($claims->sid)->toBe('store-a') + ->and($claims->env)->toBe('live') + ->and($claims->adm)->toBeFalse() + ->and($claims->scp)->toBeNull(); +}); + +test('customer socket token minted with a network key is scoped to the network', function () { + storefrontSocketEnable(); + + $response = (new CustomerController())->socketToken(storefrontSocketTokenRequest('network_key_a', 'customer-secret-a')); + $claims = SocketToken::verify($response->getData(true)['token']); + + expect($claims->kind)->toBe('customer') + ->and($claims->sid)->toBe('network-a') + ->and($claims->cid)->toBe('company-a'); +}); + +test('checkout principal is limited by scope to its own checkout channel', function () { + $storeCheckout = Checkout::where('uuid', 'checkout-a')->firstOrFail(); + $networkCheckout = Checkout::where('uuid', 'checkout-guest')->firstOrFail(); + $orphanCheckout = Checkout::where('uuid', 'checkout-orphan')->firstOrFail(); + + $store = StorefrontSocket::checkoutPrincipal($storeCheckout); + $network = StorefrontSocket::checkoutPrincipal($networkCheckout); + $orphan = StorefrontSocket::checkoutPrincipal($orphanCheckout); + + expect($store->kind)->toBe('checkout') + ->and($store->sub)->toBe('checkout-a') + ->and($store->ids)->toBe(['checkout-a', 'chkt_aaaaaaa']) + ->and($store->cid)->toBe('company-a') + ->and($store->cpid)->toBe('company_aaaaaaa') + ->and($store->scp)->toBe(['checkout.chkt_aaaaaaa']) + ->and($store->sid)->toBe('store-a') + ->and($store->env)->toBe('live') + ->and($network->sid)->toBe('network-a') + ->and($network->scp)->toBe(['checkout.chkt_guest01']) + ->and($orphan->cid)->toBeNull() + ->and($orphan->cpid)->toBeNull() + ->and(StorefrontSocket::checkoutChannel($storeCheckout))->toBe('checkout.chkt_aaaaaaa'); +}); + +test('checkout socket token is only minted while socket auth is enabled', function () { + $checkout = Checkout::where('uuid', 'checkout-guest')->firstOrFail(); + + $disabled = StorefrontSocket::checkoutToken($checkout); + storefrontSocketEnable(); + $enabled = StorefrontSocket::checkoutToken($checkout); + $claims = SocketToken::verify($enabled['token']); + + expect($disabled)->toBeNull() + ->and(array_keys($enabled))->toBe(['token', 'expires_in', 'expires_at']) + ->and($claims->kind)->toBe('checkout') + ->and($claims->sub)->toBe('checkout-guest') + ->and($claims->scp)->toBe(['checkout.chkt_guest01']) + ->and($claims->cid)->toBe('company-a'); +}); + +test('storefront channels resolve stores and networks by uuid public id or key within the company', function () { + $user = storefrontSocketPrincipal('user'); + $api = storefrontSocketPrincipal('api'); + + expect(StorefrontSocket::authorizeStorefront($user, 'store-a', 'storefront.store-a'))->toBeTrue() + ->and(StorefrontSocket::authorizeStorefront($user, 'store_aaaaaaa', 'storefront.store_aaaaaaa'))->toBeTrue() + ->and(StorefrontSocket::authorizeStorefront($user, 'store_key_a', 'storefront.store_key_a'))->toBeTrue() + ->and(StorefrontSocket::authorizeStorefront($api, 'network_aaaaaaa', 'storefront.network_aaaaaaa'))->toBeTrue() + ->and(StorefrontSocket::authorizeStorefront($user, 'store_bbbbbbb', 'storefront.store_bbbbbbb'))->toBeFalse() + ->and(StorefrontSocket::authorizeStorefront($user, 'store_orphan1', 'storefront.store_orphan1'))->toBeFalse() + ->and(StorefrontSocket::authorizeStorefront($user, 'store_missing', 'storefront.store_missing'))->toBeFalse(); +}); + +test('storefront channels let a customer subscribe only to the storefront their token names', function () { + $customer = storefrontSocketPrincipal('customer', ['sub' => 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', 'sid' => 'store-a']); + $networkMember = storefrontSocketPrincipal('customer', ['sub' => 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', 'sid' => 'network-a']); + $unscoped = storefrontSocketPrincipal('customer', ['sub' => 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa']); + $foreign = storefrontSocketPrincipal('customer', ['sub' => 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', 'cid' => 'company-b', 'sid' => 'store-a']); + $driver = storefrontSocketPrincipal('driver', ['sid' => 'store-a']); + + expect(StorefrontSocket::authorizeStorefront($customer, 'store_aaaaaaa', 'storefront.store_aaaaaaa'))->toBeTrue() + ->and(StorefrontSocket::authorizeStorefront($customer, 'store_aaaaaa2', 'storefront.store_aaaaaa2'))->toBeFalse() + ->and(StorefrontSocket::authorizeStorefront($networkMember, 'network-a', 'storefront.network-a'))->toBeTrue() + ->and(StorefrontSocket::authorizeStorefront($networkMember, 'store-a', 'storefront.store-a'))->toBeFalse() + ->and(StorefrontSocket::authorizeStorefront($unscoped, 'store-a', 'storefront.store-a'))->toBeFalse() + ->and(StorefrontSocket::authorizeStorefront($foreign, 'store-a', 'storefront.store-a'))->toBeFalse() + ->and(StorefrontSocket::authorizeStorefront($driver, 'store-a', 'storefront.store-a'))->toBeFalse(); +}); + +test('checkout channels allow the company and the owning customer only', function () { + $user = storefrontSocketPrincipal('user'); + $api = storefrontSocketPrincipal('api'); + $owner = storefrontSocketPrincipal('customer', ['sub' => 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', 'sid' => 'store-a']); + $stranger = storefrontSocketPrincipal('customer', ['sub' => 'contact-other', 'sid' => 'store-a']); + $foreign = storefrontSocketPrincipal('customer', ['sub' => 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', 'cid' => 'company-b', 'sid' => 'store-b']); + $checkout = storefrontSocketPrincipal('checkout', ['sub' => 'checkout-a', 'scp' => ['checkout.chkt_aaaaaaa']]); + $driver = storefrontSocketPrincipal('driver'); + + expect(StorefrontSocket::authorizeCheckout($user, 'chkt_aaaaaaa', 'checkout.chkt_aaaaaaa'))->toBeTrue() + ->and(StorefrontSocket::authorizeCheckout($api, 'checkout-a', 'checkout.checkout-a'))->toBeTrue() + ->and(StorefrontSocket::authorizeCheckout($user, 'chkt_bbbbbbb', 'checkout.chkt_bbbbbbb'))->toBeFalse() + ->and(StorefrontSocket::authorizeCheckout($user, 'chkt_orphan1', 'checkout.chkt_orphan1'))->toBeFalse() + ->and(StorefrontSocket::authorizeCheckout($user, 'chkt_missing', 'checkout.chkt_missing'))->toBeFalse() + ->and(StorefrontSocket::authorizeCheckout($owner, 'chkt_aaaaaaa', 'checkout.chkt_aaaaaaa'))->toBeTrue() + ->and(StorefrontSocket::authorizeCheckout($owner, 'chkt_guest01', 'checkout.chkt_guest01'))->toBeFalse() + ->and(StorefrontSocket::authorizeCheckout($stranger, 'chkt_aaaaaaa', 'checkout.chkt_aaaaaaa'))->toBeFalse() + ->and(StorefrontSocket::authorizeCheckout($foreign, 'chkt_aaaaaaa', 'checkout.chkt_aaaaaaa'))->toBeFalse() + ->and(StorefrontSocket::authorizeCheckout($checkout, 'chkt_aaaaaaa', 'checkout.chkt_aaaaaaa'))->toBeFalse() + ->and(StorefrontSocket::authorizeCheckout($driver, 'chkt_aaaaaaa', 'checkout.chkt_aaaaaaa'))->toBeFalse(); +}); + +test('storefront registers its storefront and checkout channel resolvers', function () { + $registry = new SocketChannelRegistry(); + + StorefrontSocket::registerChannels($registry); + + $storefront = $registry->resolve('storefront'); + $checkout = $registry->resolve('checkout'); + $customer = storefrontSocketPrincipal('customer', ['sub' => 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', 'sid' => 'store-a']); + + expect($storefront)->toBeInstanceOf(Closure::class) + ->and($checkout)->toBeInstanceOf(Closure::class) + ->and($storefront($customer, 'store-a', 'storefront.store-a'))->toBeTrue() + ->and($checkout($customer, 'chkt_aaaaaaa', 'checkout.chkt_aaaaaaa'))->toBeTrue() + ->and($checkout($customer, 'chkt_bbbbbbb', 'checkout.chkt_bbbbbbb'))->toBeFalse(); +}); + +test('customer principal is built from the customer and the storefront of the key', function () { + $customer = Contact::where('uuid', 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa')->firstOrFail(); + $store = Store::where('uuid', 'store-a')->firstOrFail(); + $network = Network::where('uuid', 'network-a')->firstOrFail(); + + $forStore = StorefrontSocket::customerPrincipal($customer, $store); + $forNetwork = StorefrontSocket::customerPrincipal($customer, $network); + + expect($forStore->toClaims())->toBe([ + 'kind' => 'customer', + 'sub' => 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + 'cid' => 'company-a', + 'cpid' => 'company_aaaaaaa', + 'env' => 'live', + 'ids' => ['aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', 'contact_aaaaaaa', 'user-a'], + 'adm' => false, + 'sid' => 'store-a', + ])->and($forNetwork->sid)->toBe('network-a') + // A customer without a user account has only its contact ids. + ->and(StorefrontSocket::customerPrincipal(Contact::where('uuid', 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb')->firstOrFail(), $store)->ids) + ->toBe(['bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', 'contact_bbbbbbb']); +}); diff --git a/tests/integration/components/shared-identity-test.js b/tests/integration/components/shared-identity-test.js new file mode 100644 index 00000000..00bf135c --- /dev/null +++ b/tests/integration/components/shared-identity-test.js @@ -0,0 +1,71 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'dummy/tests/helpers'; +import { render } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; +import { initialize } from '@fleetbase/fleetops-data/instance-initializers/register-shared-resource-descriptors'; + +/** + * Storefront never loads the FleetOps engine to show a driver, customer or + * place: the descriptors and wrappers come from fleetops-data and the + * generic components from ember-ui, both of which this engine bundles. + */ +module('Integration | Component | shared identity components', function (hooks) { + setupRenderingTest(hooks); + + hooks.beforeEach(function () { + initialize(this.owner); + }); + + test('a driver identity cell renders the name, status dot and vehicle badge from the shared descriptor', async function (assert) { + this.set('row', { driver_assigned: { resourceType: 'driver', name: 'Ada Driver', status: 'available', vehicle_name: 'Truck 10' } }); + this.set('column', { resourceType: 'driver', resourcePath: 'driver_assigned', popover: false }); + + await render(hbs``); + + assert.dom('[data-test-identity-label]').hasText('Ada Driver'); + assert.dom('[data-test-resource-identity-status-dot]').hasClass('text-green-500'); + assert.dom('[data-test-resource-identity-meta-badge][data-badge-key="vehicle"]').hasText('Truck 10'); + }); + + test('an empty relation renders the column empty text instead of a cell', async function (assert) { + this.set('row', {}); + this.set('column', { resourceType: 'driver', resourcePath: 'driver_assigned', emptyText: 'No driver assigned' }); + + await render(hbs``); + + assert.dom('[data-test-identity-empty-text]').hasText('No driver assigned'); + assert.dom('[data-test-identity-cell]').doesNotExist(); + }); + + test('the customer, driver, place and vehicle pills and select options resolve inside storefront', async function (assert) { + this.set('customer', { resourceType: 'customer', name: 'Ava Chen', customer_type: 'fleet-ops:contact' }); + this.set('driver', { resourceType: 'driver', name: 'Ada Driver', phone: '+15550100' }); + this.set('place', { resourceType: 'place', name: 'Depot', city: 'Singapore', country: 'SG' }); + this.set('vehicle', { resourceType: 'vehicle', displayName: 'Truck 10', plate_number: 'TRK-10' }); + + await render(hbs` +
+
+
+
+
+ `); + + assert.dom('[data-test-customer] [data-test-resource-pill-title]').hasText('Ava Chen'); + assert.dom('[data-test-customer] [data-test-resource-pill-subtitle]').hasText('Customer'); + assert.dom('[data-test-driver] [data-test-resource-pill-title]').hasText('Ada Driver'); + assert.dom('[data-test-driver] [data-test-resource-pill-subtitle]').hasText('+15550100'); + assert.dom('[data-test-place] [data-test-resource-pill-title]').hasText('Depot'); + assert.dom('[data-test-place] [data-test-resource-pill-subtitle]').hasText('Singapore, SG'); + assert.dom('[data-test-vehicle] [data-test-select-option-title]').hasText('Truck 10'); + assert.dom('[data-test-vehicle] .select-option').hasClass('select-option--compact'); + assert.dom('[data-test-driver-option] [data-test-select-option-title]').hasText('Ada Driver'); + }); + + test('a pill with no record shows its fallback title and does not open anything', async function (assert) { + await render(hbs``); + + assert.dom('[data-test-resource-pill-title]').hasText('No driver assigned'); + assert.dom('[data-test-resource-pill] a[href]').doesNotExist(); + }); +}); diff --git a/tests/integration/components/storefront/order/details-test.js b/tests/integration/components/storefront/order/details-test.js index 1d392fca..981b8944 100644 --- a/tests/integration/components/storefront/order/details-test.js +++ b/tests/integration/components/storefront/order/details-test.js @@ -2,10 +2,15 @@ import { module, test } from 'qunit'; import { setupRenderingTest } from 'dummy/tests/helpers'; import { render } from '@ember/test-helpers'; import { hbs } from 'ember-cli-htmlbars'; +import { initialize } from '@fleetbase/fleetops-data/instance-initializers/register-shared-resource-descriptors'; module('Integration | Component | storefront/order/details', function (hooks) { setupRenderingTest(hooks); + hooks.beforeEach(function () { + initialize(this.owner); + }); + test('it renders dedicated storefront order detail panels', async function (assert) { this.set('order', { public_id: 'order_test', @@ -58,9 +63,30 @@ module('Integration | Component | storefront/order/details', function (hooks) { assert.dom('.next-content-panel-title-container').hasTextContaining('Route'); assert.dom('.next-content-panel-title-container').hasTextContaining('Metadata'); assert.dom('.storefront-order-person__name').hasTextContaining('Tasty Store'); + assert.dom('[data-test-order-driver-pill] [data-test-resource-pill-title]').hasText('No driver assigned', 'an unassigned driver is a static pill with the fallback title'); + assert.dom('[data-test-order-customer-pill] [data-test-resource-pill-title]').hasText('No customer'); assert.dom('.storefront-order-item__image').exists(); assert.dom('.storefront-order-item__price').exists(); assert.dom().doesNotContainText('Delivery Address'); assert.dom().doesNotContainText('Pickup Address'); }); + + test('a loaded customer and driver render as their pills', async function (assert) { + this.set('order', { + public_id: 'order_test', + meta: { total: 10, currency: 'USD', is_pickup: true }, + customer: { id: 'contact_1', resourceType: 'customer', name: 'Ava Chen', email: 'ava@example.test', phone: '+15550201' }, + driver_assigned: { id: 'driver_1', resourceType: 'driver', name: 'Ada Driver', phone: '+15550100' }, + payload: { pickup: {}, dropoff: {}, entities: [] }, + tracking_number: {}, + tracking_statuses: [], + files: [], + }); + + await render(hbs``); + + assert.dom('[data-test-order-customer-pill] [data-test-resource-pill-title]').hasText('Ava Chen'); + assert.dom('[data-test-order-driver-pill] [data-test-resource-pill-title]').hasText('Ada Driver'); + assert.dom('[data-test-order-driver-pill] [data-test-resource-pill-subtitle]').hasText('+15550100'); + }); }); diff --git a/tests/integration/components/widget/customers-test.js b/tests/integration/components/widget/customers-test.js index 9d51d239..4aa03639 100644 --- a/tests/integration/components/widget/customers-test.js +++ b/tests/integration/components/widget/customers-test.js @@ -57,7 +57,7 @@ module('Integration | Component | widget/customers', function (hooks) { assert.dom('.storefront-widget-count').hasText('1'); assert.dom('.storefront-customers-table').exists(); assert.dom('.storefront-customer-id').hasText('contact_1'); - assert.dom('.storefront-customers-table').includesText('Ava Chen'); + assert.dom('.storefront-customers-table [data-test-resource-pill-title]').hasText('Ava Chen', 'the customer renders as its pill'); assert.dom('.storefront-customers-table').includesText('ava.chen@example.test'); }); }); diff --git a/tests/unit/controllers/customers/index-test.js b/tests/unit/controllers/customers/index-test.js index 1acfcb49..d8189724 100644 --- a/tests/unit/controllers/customers/index-test.js +++ b/tests/unit/controllers/customers/index-test.js @@ -17,4 +17,13 @@ module('Unit | Controller | customers/index', function (hooks) { assert.true(nameColumn.sticky, 'name column is sticky'); assert.strictEqual(actionColumn.sticky, 'right', 'action column is sticky on the right'); }); + + test('the name column is a customer identity cell that opens the storefront customer', function (assert) { + let controller = this.owner.lookup('controller:customers/index'); + let [nameColumn] = controller.columns; + + assert.strictEqual(nameColumn.cellComponent, 'table/cell/identity'); + assert.strictEqual(nameColumn.resourceType, 'customer'); + assert.strictEqual(nameColumn.action, controller.viewCustomer); + }); }); diff --git a/tests/unit/controllers/networks/index/network/customers-test.js b/tests/unit/controllers/networks/index/network/customers-test.js index 844ec516..bd90c9fb 100644 --- a/tests/unit/controllers/networks/index/network/customers-test.js +++ b/tests/unit/controllers/networks/index/network/customers-test.js @@ -4,9 +4,17 @@ import { setupTest } from 'dummy/tests/helpers'; module('Unit | Controller | networks/index/network/customers', function (hooks) { setupTest(hooks); - // TODO: Replace this with your real tests. test('it exists', function (assert) { let controller = this.owner.lookup('controller:networks/index/network/customers'); assert.ok(controller); }); + + test('the name column is a customer identity cell that opens the customer panel', function (assert) { + let controller = this.owner.lookup('controller:networks/index/network/customers'); + let [nameColumn] = controller.columns; + + assert.strictEqual(nameColumn.cellComponent, 'table/cell/identity'); + assert.strictEqual(nameColumn.resourceType, 'customer'); + assert.strictEqual(nameColumn.action, controller.viewCustomer); + }); }); diff --git a/tests/unit/controllers/networks/index/network/orders-test.js b/tests/unit/controllers/networks/index/network/orders-test.js index 59ef20f3..141a91cc 100644 --- a/tests/unit/controllers/networks/index/network/orders-test.js +++ b/tests/unit/controllers/networks/index/network/orders-test.js @@ -4,9 +4,28 @@ import { setupTest } from 'dummy/tests/helpers'; module('Unit | Controller | networks/index/network/orders', function (hooks) { setupTest(hooks); - // TODO: Replace this with your real tests. test('it exists', function (assert) { let controller = this.owner.lookup('controller:networks/index/network/orders'); assert.ok(controller); }); + + test('customer, pickup, dropoff and driver are identity cells typed for the shared descriptors', function (assert) { + const controller = this.owner.lookup('controller:networks/index/network/orders'); + const byId = Object.fromEntries(controller.columns.map((column) => [column.id, column])); + + for (const [id, resourceType] of [ + ['customer-name', 'customer'], + ['pickup-name', 'place'], + ['dropoff-name', 'place'], + ['driver-assigned', 'driver'], + ]) { + assert.strictEqual(byId[id].cellComponent, 'table/cell/identity', `${id} is an identity cell`); + assert.strictEqual(byId[id].resourceType, resourceType, `${id} is a ${resourceType}`); + assert.strictEqual(typeof byId[id].resourcePath, 'function', `${id} resolves its resource`); + } + + const driver = { id: 'driver_1' }; + assert.strictEqual(byId['driver-assigned'].resourcePath({ driver_assigned: driver }), driver); + assert.strictEqual(byId['driver-assigned'].resourcePath({ driver_name: 'Bob' }).name, 'Bob'); + }); }); diff --git a/tests/unit/controllers/orders/index-test.js b/tests/unit/controllers/orders/index-test.js index b471c95b..6c9ca5d4 100644 --- a/tests/unit/controllers/orders/index-test.js +++ b/tests/unit/controllers/orders/index-test.js @@ -1,5 +1,6 @@ import { module, test } from 'qunit'; import { setupTest } from 'dummy/tests/helpers'; +import Service from '@ember/service'; module('Unit | Controller | orders/index', function (hooks) { setupTest(hooks); @@ -17,4 +18,69 @@ module('Unit | Controller | orders/index', function (hooks) { assert.true(idColumn.sticky, 'id column is sticky'); assert.strictEqual(actionColumn.sticky, 'right', 'action column is sticky on the right'); }); + + test('customer, pickup, dropoff and driver are identity cells typed for the shared descriptors', function (assert) { + const controller = this.owner.lookup('controller:orders/index'); + const byId = Object.fromEntries(controller.columns.map((column) => [column.id, column])); + + assert.strictEqual(byId['customer-name'].cellComponent, 'table/cell/identity'); + assert.strictEqual(byId['customer-name'].resourceType, 'customer'); + assert.strictEqual(byId['pickup-name'].cellComponent, 'table/cell/identity'); + assert.strictEqual(byId['pickup-name'].resourceType, 'place'); + assert.strictEqual(byId['dropoff-name'].cellComponent, 'table/cell/identity'); + assert.strictEqual(byId['dropoff-name'].resourceType, 'place'); + assert.strictEqual(byId['driver-assigned'].cellComponent, 'table/cell/identity'); + assert.strictEqual(byId['driver-assigned'].resourceType, 'driver'); + assert.strictEqual(byId['driver-assigned'].emptyText, 'No driver assigned'); + }); + + test('identity cells read the loaded relation, or a stub named after the row', async function (assert) { + const controller = this.owner.lookup('controller:orders/index'); + const byId = Object.fromEntries(controller.columns.map((column) => [column.id, column])); + const customer = { id: 'contact_1', name: 'Ada' }; + const driver = { id: 'driver_1', name: 'Bob' }; + const pickup = { id: 'place_1', name: 'Depot' }; + + assert.strictEqual(byId['customer-name'].resourcePath({ customer }), customer); + assert.strictEqual(byId['driver-assigned'].resourcePath({ driver_assigned: driver }), driver); + assert.strictEqual(byId['pickup-name'].resourcePath({ payload: { pickup } }), pickup); + + const customerStub = byId['customer-name'].resourcePath({ customer_name: 'Ada', customer_type: 'fleet-ops:contact' }); + assert.strictEqual(customerStub.name, 'Ada'); + assert.strictEqual(customerStub.resourceType, 'fleet-ops:contact'); + assert.true(customerStub.isIdentityStub); + + const placeStub = byId['dropoff-name'].resourcePath({ dropoffName: '1 Main St' }); + assert.strictEqual(placeStub.name, '1 Main St'); + assert.strictEqual(placeStub.resourceType, 'place'); + + const found = { id: 'driver_9' }; + const findRecord = (modelName, id) => (modelName === 'driver' && id === 'driver_9' ? Promise.resolve(found) : Promise.resolve(null)); + controller.store = { findRecord }; + const driverStub = byId['driver-assigned'].resourcePath({ driver_name: 'Bob', driver_assigned_uuid: 'driver_9' }); + assert.strictEqual(driverStub.name, 'Bob'); + assert.strictEqual(await driverStub.loadResource(), found, 'the stub loads the driver by uuid on demand'); + assert.strictEqual(await byId['driver-assigned'].resourcePath({ driver_name: 'Bob' }).loadResource(), null, 'no uuid, nothing to load'); + + assert.strictEqual(byId['driver-assigned'].resourcePath({}), null, 'no driver at all renders the empty text'); + }); + + test('clicking a customer opens the storefront customer page, and a name-only stub opens nothing', function (assert) { + const transitions = []; + this.owner.register( + 'service:hostRouter', + class extends Service { + transitionTo(...args) { + transitions.push(args); + } + } + ); + const controller = this.owner.lookup('controller:orders/index'); + + controller.viewCustomer({ public_id: 'contact_1', name: 'Ada' }); + controller.viewCustomer({ name: 'Ada', isIdentityStub: true }); + controller.viewCustomer(null); + + assert.deepEqual(transitions, [['console.storefront.customers.index.view', 'contact_1']]); + }); }); diff --git a/translations/ar-ae.yaml b/translations/ar-ae.yaml index b19c5612..fb433bfc 100644 --- a/translations/ar-ae.yaml +++ b/translations/ar-ae.yaml @@ -404,6 +404,7 @@ storefront: enable-tips: تفعيل الإكراميات enable-delivery-tips: تفعيل إكراميات التوصيل enable-multi-cart-checkout: تفعيل الدفع لعربات متعددة + split-tips-across-stores: "تقسيم الإكراميات بين المتاجر في الطلبات متعددة المتاجر" enable-user-reviews: تفعيل تقييمات المستخدمين api-panel: panel-title: واجهة برمجة التطبيقات diff --git a/translations/bg-bg.yaml b/translations/bg-bg.yaml index eebf92e4..ec596968 100644 --- a/translations/bg-bg.yaml +++ b/translations/bg-bg.yaml @@ -422,6 +422,7 @@ storefront: enable-tips: Активиране на бакшиши enable-delivery-tips: Активиране на бакшиши за доставка enable-multi-cart-checkout: Активиране на плащане с няколко колички + split-tips-across-stores: "Разпределяне на бакшишите между магазините при поръчки от няколко магазина" enable-user-reviews: Активиране на потребителски отзиви api-panel: panel-title: API diff --git a/translations/en-us.yaml b/translations/en-us.yaml index 4995aeb3..6ed1d6bd 100644 --- a/translations/en-us.yaml +++ b/translations/en-us.yaml @@ -394,6 +394,7 @@ storefront: enable-tips: Enable tips enable-delivery-tips: Enable delivery tips enable-multi-cart-checkout: Enable multi-cart checkout + split-tips-across-stores: "Split store tips across stores in multi-store orders" enable-user-reviews: Enable user reviews api-panel: panel-title: API diff --git a/translations/es-es.yaml b/translations/es-es.yaml index 62bb3713..f120758d 100644 --- a/translations/es-es.yaml +++ b/translations/es-es.yaml @@ -420,6 +420,7 @@ storefront: enable-tips: Habilitar propinas enable-delivery-tips: Habilitar propinas para entrega enable-multi-cart-checkout: Habilitar pago de múltiples carritos + split-tips-across-stores: "Repartir las propinas entre las tiendas en pedidos de varias tiendas" enable-user-reviews: Habilitar reseñas de usuarios api-panel: panel-title: API diff --git a/translations/fr-fr.yaml b/translations/fr-fr.yaml index 75d2880f..3fa3a392 100644 --- a/translations/fr-fr.yaml +++ b/translations/fr-fr.yaml @@ -433,6 +433,7 @@ storefront: enable-tips: Activer les pourboires enable-delivery-tips: Activer les pourboires de livraison enable-multi-cart-checkout: Activer le paiement multi-panier + split-tips-across-stores: "Répartir les pourboires entre les boutiques dans les commandes multi-boutiques" enable-user-reviews: Activer les avis utilisateurs api-panel: panel-title: API diff --git a/translations/mn-mn.yaml b/translations/mn-mn.yaml index 38aa4ffb..d7cdff31 100644 --- a/translations/mn-mn.yaml +++ b/translations/mn-mn.yaml @@ -414,6 +414,7 @@ storefront: enable-tips: Зөвлөмжийг идэвхжүүлэх enable-delivery-tips: Хүргэлтийн зөвлөмжийг идэвхжүүлэх enable-multi-cart-checkout: Олон сагснаас төлбөр хийхийг идэвхжүүлэх + split-tips-across-stores: "Олон дэлгүүрийн захиалгад цайны мөнгийг дэлгүүрүүдэд хуваах" enable-user-reviews: Хэрэглэгчийн үнэлгээг идэвхжүүлэх api-panel: panel-title: API diff --git a/translations/pt-br.yaml b/translations/pt-br.yaml index 84bd755e..e4d2dc60 100644 --- a/translations/pt-br.yaml +++ b/translations/pt-br.yaml @@ -421,6 +421,7 @@ storefront: enable-tips: Ativar gorjetas enable-delivery-tips: Ativar gorjetas para entrega enable-multi-cart-checkout: Ativar checkout para múltiplos carrinhos + split-tips-across-stores: "Dividir as gorjetas entre as lojas em pedidos de várias lojas" enable-user-reviews: Ativar avaliações de usuários api-panel: panel-title: API diff --git a/translations/ru-ru.yaml b/translations/ru-ru.yaml index df34f2ae..d815e0f5 100644 --- a/translations/ru-ru.yaml +++ b/translations/ru-ru.yaml @@ -418,6 +418,7 @@ storefront: enable-delivery-tips: Включить чаевые за доставку enable-multi-cart-checkout: Включить оформление заказа из нескольких корзин + split-tips-across-stores: "Распределять чаевые между магазинами в заказах из нескольких магазинов" enable-user-reviews: Включить отзывы пользователей api-panel: panel-title: API diff --git a/translations/uk-ua.yaml b/translations/uk-ua.yaml index 38fe4995..848cd75d 100644 --- a/translations/uk-ua.yaml +++ b/translations/uk-ua.yaml @@ -386,6 +386,7 @@ storefront: enable-tips: Увімкнути чайові enable-delivery-tips: Увімкнути чайові кур'єру enable-multi-cart-checkout: Увімкнути оформлення з кількох кошиків + split-tips-across-stores: "Розподіляти чайові між магазинами в замовленнях із кількох магазинів" enable-user-reviews: Увімкнути відгуки користувачів api-panel: panel-title: API diff --git a/translations/vi-vn.yaml b/translations/vi-vn.yaml index e4d5d1a0..effb1087 100644 --- a/translations/vi-vn.yaml +++ b/translations/vi-vn.yaml @@ -417,6 +417,7 @@ storefront: enable-tips: Bật tiền boa enable-delivery-tips: Bật tiền boa cho giao hàng enable-multi-cart-checkout: Bật thanh toán nhiều giỏ hàng + split-tips-across-stores: "Chia tiền tip cho các cửa hàng trong đơn hàng nhiều cửa hàng" enable-user-reviews: Bật đánh giá người dùng api-panel: panel-title: API diff --git a/translations/zh-cn.yaml b/translations/zh-cn.yaml index 28fcab63..ac31946e 100644 --- a/translations/zh-cn.yaml +++ b/translations/zh-cn.yaml @@ -382,6 +382,7 @@ storefront: enable-tips: 启用小费 enable-delivery-tips: 启用配送小费 enable-multi-cart-checkout: 启用多购物车结账 + split-tips-across-stores: "在多店铺订单中将小费分配给各店铺" enable-user-reviews: 启用用户评价 api-panel: panel-title: API
{{format-currency order.meta.total order.meta.currency}}{{n-a order.customer.name}} {{#if order.meta.is_pickup}} {{t "storefront.component.widget.orders.pickup-order" }} {{else}} - {{n-a order.driver_assigned.name}} + {{/if}}