Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions docs/promotions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
27 changes: 21 additions & 6 deletions server/src/Http/Controllers/v1/PromotionController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand All @@ -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']);
}
}
6 changes: 5 additions & 1 deletion server/src/Http/Resources/Promotion.php
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
155 changes: 155 additions & 0 deletions server/src/Models/Promotion.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -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<string, array<int, string>>
*/
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);
Expand Down
Loading
Loading