Skip to content

feat(promotions): store attribution, shareable code and availability on public promotions - #116

Merged
roncodes merged 1 commit into
release/v0.4.25from
feat/public-promotion-details
Oct 7, 2026
Merged

roncodes merged 1 commit into
release/v0.4.25from
feat/public-promotion-details

Conversation

@roncodes

@roncodes roncodes commented Oct 6, 2026

Copy link
Copy Markdown
Member

Summary

The customer app redesign adds an Offers list and an offer detail screen, linked from home, store pages and campaign notifications. The public promotion API couldn't support them:

  • It didn't say which store runs an offer. The owner is internal-only, so a network app couldn't show "Offer from Bloom & Co." or link to the store.
  • Code promotions had no way to show the code to type.
  • applies_to held internal uuids, so "Shop the offer" couldn't link to products, categories or stores.
  • Promotions with weekly hours ("weekdays 2–5 pm") vanished from the list outside those hours, and their detail returned 404. A campaign push sent at 1 pm would open a dead link, and the app couldn't say "starts again at 2 pm" or "this offer ended".

This PR adds those fields to the public promotion resource. It also lets the list and detail endpoints return scheduled and ended promotions; the list does this only on request.

Related Issue

Part of the storefront-app redesign (Offers list, offer detail and campaign deep links).

Type of Change

  • Bug fix
  • Feature
  • Refactor
  • Documentation
  • Test
  • Chore

Implementation Notes

Promotion model

availabilityAt() returns one of:

  • live: running now;
  • scheduled: active, but before starts_at or outside its weekly hours;
  • ended: status ended, or past ends_at;
  • inactive: draft or paused.

nextLiveAt() applies to scheduled promotions only. It returns the next moment the promotion applies, using the later of now and starts_at, the weekly windows in the promotion's timezone (including windows past midnight), and ends_at. It looks up to a week ahead; a future starts_at with no weekly hours is returned whatever its distance.

shareableCode() applies to code promotions only. It returns the newest code that is all of these:

  • active;
  • not assigned to a customer;
  • not expired;
  • usable more than once (usage_limit null or > 1).

So batches of single-use codes are never exposed. Codes are read from the eager-loaded relation when present.

ownerSummary() returns {type: store|network, id, name, logo_url}. logo_url is null when there is no logo, so the app can draw its monogram instead of the generic file icon.

publicAppliesTo() maps products, exclude_products, stores, categories and exclude_categories to public ids. It accepts either uuids or public ids, like PromotionLine::matches*, and drops ids that no longer resolve.

Public resource

On public routes it adds owner, code, availability and next_starts_at, and returns applies_to with public ids. Internal (Console) responses keep raw applies_to and don't include owner or code. availability and next_starts_at appear on both, because they only need the promotion's own fields.

v1 PromotionController

  • GET promotions is unchanged by default: live promotions only. include=scheduled adds scheduled ones. Ended, draft and paused promotions are never listed.
  • GET promotions/{id} now returns scheduled and ended public promotions, so links from notifications keep working. It still returns 404 for draft, paused, non-public and out-of-context promotions.
  • Owners and codes are eager-loaded with the image.

Docs: docs/promotions.md explains when a code appears in the customer app and that scheduled offers stay visible.

Validation

  • Tests
  • Lint
  • Build
  • Manual validation

Command output / summary:

Full backend suite (local, PHP 8.4, Pest)
OK (585 tests, 3512 assertions)
Promotions directory: OK (97 tests, 409 assertions)

Line coverage of every changed or added line in server/src (Xdebug):
  Models/Promotion.php                       all covered
  Http/Controllers/v1/PromotionController    all covered
  Http/Resources/Promotion.php               all covered

php-cs-fixer: clean for all changed files

New server/tests/Unit/Promotions/PublicPromotionDetailsTest.php covers:

  • availability for live, outside-hours, not-started, ended (by status or by date), draft and paused promotions;
  • next start for: a later window the same day; Friday evening rolling to Monday; a far-future starts_at; a starts_at inside a window that runs past midnight; a non-UTC timezone; an ends_at before the next window; a window with no days;
  • code selection, excluding single-use, assigned, expired and disabled codes, picking the newest, and reading the loaded relation;
  • owner summaries for store, network and missing owners;
  • public applies_to mapping, including unknown and non-string ids;
  • include=scheduled, and detail for scheduled, ended, paused and draft promotions;
  • public and internal resource shapes.

Existing promotion, campaign and cart tests pass unchanged.

Documentation Impact

  • No documentation changes needed
  • Documentation updated in fleetbase/fleetbase.io (repo docs/promotions.md here; the site copy is a follow-up)
  • Documentation needed but not included

API Reference Impact

  • No API reference changes needed
  • Updated fleetbase/postman
  • API reference updates required but not included

API reference notes:

  • New query param on GET storefront/v1/promotions: include=scheduled.
  • GET storefront/v1/promotions/{id} now also returns scheduled and ended promotions.
  • New public fields: owner, code, availability and next_starts_at.
  • applies_to now contains public ids on public routes.

Documentation Notes

The storefront API docs and the Postman collection need the fields and parameter above.

Risk

  • Detail endpoint. Clients that assumed a 200 from GET promotions/{id} meant "live now" should now read availability. The current storefront-app doesn't call this endpoint.
  • Exposed codes. Code promotions that are public and have a reusable unassigned code will now show that code to anyone using the storefront. That matches what "public" means for a promotion. Operators who want a code kept private should leave the promotion non-public, or hand codes out through a campaign.
  • Extra queries. applies_to mapping adds up to five small whereIn lookups per promotion that has targets. Public lists are short.

…on public promotions

- Public promotions name the store or network running them (owner: type, id,
  name, logo_url) and list applies_to targets by public id instead of uuid.
- Code promotions show their code publicly when they have one reusable,
  unassigned, unexpired code; single-use batch codes are never exposed.
- Every promotion reports availability (live, scheduled, ended) and, when
  scheduled, next_starts_at from its date range and weekly hours.
- GET promotions?include=scheduled also lists promotions outside their weekly
  hours or before their start; GET promotions/{id} opens scheduled and ended
  promotions so links from notifications keep working.
@codecov

codecov Bot commented Oct 6, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 100.00%. Comparing base (62af547) to head (a406dd2).
⚠️ Report is 3 commits behind head on main.

Additional details and impacted files
@@             Coverage Diff             @@
##                main      #116   +/-   ##
===========================================
  Coverage     100.00%   100.00%           
- Complexity      2282      2321   +39     
===========================================
  Files            182       182           
  Lines           9082      9168   +86     
===========================================
+ Hits            9082      9168   +86     
Flag Coverage Δ
backend 100.00% <100.00%> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@roncodes
roncodes changed the base branch from main to release/v0.4.25 October 6, 2026 19:14
@roncodes
roncodes merged commit ca4a2f6 into release/v0.4.25 Oct 7, 2026
11 checks passed
@roncodes
roncodes deleted the feat/public-promotion-details branch October 7, 2026 05:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant