Skip to main content

Shipping Notes — Generation and Cycles

This page describes how each Shipping Note is born and how dispatch cycles are completed. It covers everything from the cart mounting the shipping payment to the recurring creation of SNs in annual subscriptions, including idempotency guarantees and flow edge cases.

Overview

Each Shipping Note is born from a shipping payment (a payment with sale_type = 'shipping') that has been approved. ShippingNoteService reads the order's shipping_information and creates the first SN (shipment_number = 1) when the charge is confirmed.

From there, the path depends on the sale type. In one-off purchases and monthly subscriptions that payment produces a single SN, and subsequent cycles enter through the same flow when the next charge arrives.

Annuals are different. The first payment covers all twelve shipments for the year, so the first SN is created when the charge is approved and the remaining eleven are generated by a scheduled job, one per month, until reaching the hard cap of twelve shipments.

This document follows the complete journey: how the cart mounts the shipping payment, how PaymentService triggers the creation of the first SN, how the job builds the subsequent SNs for annuals, and what idempotency guarantees exist at each step.

How the shipping payment enters the order

Before talking about SNs, it is necessary to understand where the charge that originates them comes from. During checkout, when the order has shipping_information and at least one physical product, UserProductService::addShippingPaymentItem adds an extra item to the list of payments to be created.

That item arrives typed as shipping (plan.type = shipping, which Payment::SALE_TYPE maps to sale_type = 'shipping') and copies the interval and interval_count from the main product of the order: an annual inherits interval = annual, a monthly inherits interval = month.

The amount is calculated via ShippingNoteService::getShippingPriceRecurring($interval, $price). For monthlies it returns the carrier price; for annuals it multiplies it by 12 so that a single charge covers all twelve cycles of the year. The helper accepts only month or annual and throws InvalidArgumentException for any other value.

One-off purchases short-circuit with if ($interval) and send the carrier price directly, without going through the helper.

Since the SN does not hang from a specific UP, the item travels with issue_id and user_plan_id as null. TaxRateService::resolveForShipping($order, $price) calculates tax and tax_rate, which are persisted when building the payment, keeping the shipping aligned with the order's tax regime.

Who decides whether there is a shipping payment

hasPhysicalProducts decides whether the order qualifies. The function iterates the UP collection already loaded in memory and delegates the per-UP decision to the is_physical accessor of UserPlan, which internally resolves issue.is_physical or falls back to plan.physical as appropriate.

If no UP in the order is physical, the order has no shipping payment and, consequently, no SN.

The tenant feature physicals_goods filters upstream at the catalog level: without it, physical plans and products are hidden from checkout, so the shipping branch is never evaluated.

Amount in local currency

The local currency amount comes from the carrier price for that currency. If the carrier is free, the amount is zero and the SN is still created; full mechanics in Carriers — Free shipping.

Details of the carrier model, its two-dimension categorization (type and presence of state_code), and the ProvinceShippingService that selects them in Carriers.

Payment persistence and SN creation

PaymentService::createPayment iterates all the order's payments (products + shipping) and persists them with their calculated fields. The transaction_fee_in_usd_in_cents calculation excludes the shipping payment and payments with refunded status: both receive zero fee (Shipping payments and refunds should never have transaction fees).

When the shipping payment ends with status approved, the same PaymentService::createPayment (after dispatching the sale notifications for the main charge and adjusting the subscription price if applicable) calls ShippingNoteService::maybeCreateFromPayment($order, $payment). That is where the SN is born.

First SN: maybeCreateFromPayment

The service applies a series of short validations and, if they pass, creates SN number one with period_length equal to 12 if the UP is annual, or 1 in any other case.

ValidationIf it fails
order.shipping_information has carrier_id and typeReturns null silently, with no entry in shipping_notes_process. This is the only guard without a log: if the runbook finds no trace in activity log explaining why an SN was not born, this is the most likely path.
payment.sale_type === 'shipping'Logs No shipping note created: payment is not shipping sale type in shipping_notes_process and returns null.
payment.status === 'approved'Logs No shipping note created: payment status not approved and returns null.
The carrier still existsLogs No shipping note created: carrier not found and returns null.
No other SN with the same payment_id and shipment_number = 1Logs No shipping note created: shipping note already exists for this payment and returns null. This provides idempotency against webhook retries or flow re-entries.

If all validations pass, it calls ShippingNoteService::createShippingNote with shipmentNumber = 1 and a periodLength derived from the interval of the first UP of the order (12 for annual, 1 for monthly or without interval). The initial status is order-generated.

After creating the SN, maybeCreateFromPayment writes a log to shipping_notes_process with payment_id, period_length, and shipment_number.

The user notification is dispatched from createShippingNote. Details of the audience and localization live in Lifecycle — User notifications.

Sale flows — first cycle

Physical one-off purchase (cart)

The user purchases one or more physical products in the cart. The resulting order can have N UPs (one per product), each with its payment of sale_type = retail, plus a single extra shipping payment with interval = null and period_length = 1.

When all payments are approved, maybeCreateFromPayment runs once per payment, but only the shipping payment passes the validations: a single SN is created covering all the physical products of the order together.

The courier ships all items in one shipment, not one per UP. If the tenant operationally wanted one shipment per product, this is not modeled today and would require splitting the order or creating additional SNs manually.

Physical monthly subscription

The first charge generates the first SN, with period_length = 1 and shipment_number = 1. The UP stays active and the subscription charges month by month through the gateway (Stripe, MercadoPago, PayU, or Yuno).

Each subsequent recurring charge re-enters PaymentService::createPayment with its two cycle payments (subscription + shipping), and the SN for the new cycle is created through the same path.

The payment_id of each SN points to the shipping payment of its own cycle, so the SN ↔ shipping payment relationship remains 1:1 throughout the subscription lifetime.

Physical annual subscription

The first charge is a single one covering the entire year, with period_length = 12. The SN for the first month (shipment_number = 1) is created immediately, and the eleven subsequent ones are built by the job described below (once per month) until reaching shipment_number = 12.

If the subscription is cancelled before the end of the year, the order moves to a status other than approved and subsequent SNs stop being generated: there is no partial refund or automatic adjustment of already-created SNs. This case is covered in Lifecycle.

Sale flows — subsequent cycles

Monthly: the next recurring charge

For monthly subscriptions, subsequent cycles are repetitions of the first. Each monthly gateway charge becomes a new Payment (subscription) plus a new Payment (shipping) within the same order.

Gateways dispatch the corresponding webhooks and PaymentService::createPayment runs maybeCreateFromPayment again. Since each cycle brings its own shipping payment with a different payment_id, the idempotency check where('payment_id', ...)->where('shipment_number', 1) does not block: each cycle generates its SN.

The annual job: CreateShippingNotesForSubscriptionCycles

For each annual with an approved shipping payment, eleven additional SNs need to be generated spread over the year. The operation is performed by the CreateShippingNotesForSubscriptionCycles job, scheduled in routes/console.php to run daily at 02:22.

The job creates at most one SN per payment per month. The daily run with one-month internal spacing prevents a delay in a single run from shifting the cycles.

Eligibility query and filters

The job builds a cross-tenant query over payments combining several filters on the payment and on the order.

At the payment level, it only processes shipping payments with status = approved and interval = annual: monthlies never pass through here because their SNs are already generated in the synchronous charge flow. On top of that it requires at least 27 days of age as a grace period — a shorter margin could race with the synchronous creation of the first SN when the webhook arrives late.

To ensure the first SN already exists, via whereHas('shippingNotesFromAllTenants', ...) it requires at least one SN for the payment with the same minimum age of 27 days. Payments without an SN, or with a recent SN that has not yet crossed the grace period, remain the responsibility of the synchronous flow and enter a future run.

At the order level it filters by shipping_information IS NOT NULL, order.status = approved (which discards cancelled and paused orders without an explicit deactivation mechanism), and order.created_at >= now() - 13 months. Beyond 13 months it is assumed that all twelve SNs have been created and there is no need to keep looking at that order.

When the job is invoked with an explicit orderIds (from the Nova action CreateShippingNotesForOrder), the automatic path filters (order.created_at >= -13 months and order.status = approved) are skipped, but the base filters on the payment and existing SN (both 27-day ones) still apply.

The query is iterated with chunkById(500) and within each chunk the payments are grouped by tenant_id. For each group, the job calls app('CurrentTenant')->set($tenantId) before processing, ensuring that tenant() resolves to the correct tenant during SN creation, logs, and the notification.

All thresholds are hardcoded literals in the job code and in routes/console.php: 27 days, 13 months, 02:22, tries = 3, timeout = 120, and queue LOW_PRIORITY.

They are not env vars. Changing any of them requires a deploy.

Per-payment logic

For each eligible payment the job retrieves the shipping_information from the order. If it is missing carrier_id or type, it adds to skipped_invalid_carrier and moves on.

This covers two real cases: orders that arrived corrupted from checkout and carriers that the tenant deleted after the charge.

If the data is present, the job calculates the last shipment_number and the last created_at for that payment_id. If there are already twelve SNs the cycle is complete and it adds to skipped. If the last SN was created less than one month ago it is also skipped: this is the rule that maintains one SN per month, even when the job runs every day.

It verifies that the carrier still exists. If not, it adds to skipped_invalid_carrier with an explicit log.

If all conditions pass, it calls ShippingNoteService::createShippingNote with shipmentNumber = lastShipmentNumber + 1 and periodLength = 12. The SN is born in order-generated, dispatches the user notification, and adds to shipping_notes_created.

Any exception is caught per payment, adds to failed, leaves a log in shipping_notes_process with the message and line, and allows the rest of the chunk to continue processing. If an entire run fails due to an infrastructure error (for example Valkey being down), it retries twice more before being marked as failed.

At the end the job logs its accumulated stats (total_payments_evaluated, shipping_notes_created, failed, skipped, skipped_invalid_carrier) to shipping_notes_process with the message CreateShippingNotesForSubscriptionCycles job completed. These counters allow monitoring job activity and detecting drops in generation or spikes in failures.

The cap of 12 comes from the combination of period_length = 12 and the rule lastShipmentNumber >= 12 → skip. There is nothing in the code that ties "12" to the year duration in days: it assumes 12 monthly runs plus the initial SN cover the twelve months of the plan.

If future intervals appear (semi-annual, biennial), getShippingPriceRecurring and the period_length decision in maybeCreateFromPayment would need extending, along with relaxing the hard cap of 12 in the job.

Idempotency and retries

Idempotency rests on two checks per SN level.

The first SN is protected by the duplicate guard in the validation table: if an SN with the same payment_id and shipment_number = 1 already exists, maybeCreateFromPayment returns null. This covers resent webhooks, payments that enter the endpoint twice, and the RetryShippingNotes Nova action.

Subsequent SNs in annuals rely on the lastShipmentNumber >= 12 check combined with the "less than one month since the last one" rule. Even though the job runs daily, it only creates one SN per payment when appropriate, and an interrupted and retried chunk does not duplicate.

If for some reason the first SN was never created, the super admin has the RetryShippingNotes Nova action to reprocess the approved payments of the selected orders by calling maybeCreateFromPayment again. The CreateShippingNotesForOrder action serves the opposite case: forcing the job to process a specific annual even if it falls outside the 13-month lookback. Details of both actions in Lifecycle — Nova actions.

Remediation of undue SNs in annuals

App\Jobs\OneOff\DeleteUndueAnnualShippingNotes is a manual remediation job triggered from tinker. It soft-deletes SNs tied to annual shipping payments with recurring_cycle >= 2 and created_at earlier than ten months post-order, a combination that should not exist in a legitimate annual. It supports dryRun: true for preview.

The run scope has three dimensions worth keeping in mind:

  • It requires explicit tenantIds in the constructor: it is not cross-tenant automatic and aborts if passed an empty array.
  • It filters exclusively to payments with gateway_type = Gateway::YUNO: other gateways are excluded from this remediation.
  • It is limited to the current year via now()->startOfYear() on orders.created_at.

To use it for another gateway, extend the job or write a variant for that gateway.

Generation flow edge cases

Webhook arriving before the synchronous response. If a gateway sends the webhook for the shipping payment before the checkout flow finishes, the webhook enters PaymentService::createPayment and creates the SN. The subsequent synchronous response calls the service again and the duplicate check causes it to return null without harm.

Carrier deleted while SNs are in progress. Generation of subsequent SNs for that payment breaks. The detail (what the job logs, operational alternatives, recommendation of is_available = false) lives in Carriers — Edge cases.

Currency mismatch. If the carrier has no price in the order's currency, the shipping falls to zero without an alert. Detail of the behavior (including the silent checkout filtering) in Carriers — Multi-currency pricing.

Payment with transaction_fee_in_usd_in_cents. The prorated fee calculation explicitly excludes the shipping payment (receives zero fee) and payments with refunded status. If this changes in the future, the feeEligibleProductCount calculation in PaymentService also needs reviewing to avoid counting the shipping payment and duplicating the proration.

Multi-tenant in the job. The job traverses tenants in a single run. Each batch per tenant sets the CurrentTenant before creating SNs. If the set fails for some reason, the SN is created with the tenant from the request (which in the job is null), which would break isolation. The observable symptom: SNs appearing under the wrong tenant in the dashboard, or constraint errors if the column does not accept null.

Other cross-cutting cases: free shipping in Carriers — Free shipping; post-purchase address change and subscription cancellation in Lifecycle.

References

FileRole
app/Domains/Commerce/Services/UserProductService.phpaddShippingPaymentItem, hasPhysicalProducts — builds the shipping item in the cart
app/Domains/Commerce/Services/PaymentService.phpcreatePayment — persists payments and triggers maybeCreateFromPayment
app/Domains/Commerce/Services/ShippingNoteService.phpmaybeCreateFromPayment, createShippingNote, getShippingPriceRecurring
app/Domains/Commerce/Jobs/CreateShippingNotesForSubscriptionCycles.phpDaily job for annuals: query, spacing, stats
app/Jobs/OneOff/DeleteUndueAnnualShippingNotes.phpManual remediation of undue SNs in annuals (Yuno gateway)
routes/console.phpDaily schedule 02:22
app/Nova/Commerce/Actions/CreateShippingNotesForOrder.phpManual retrigger of the job for specific orders
app/Nova/Commerce/Actions/RetryShippingNotes.phpReprocessing of approved payments without an SN
app/Domains/Commerce/Models/Payment.phpSALE_TYPE['shipping'] and associated constants
app/Domains/Commerce/Enums/BillingInterval.phpmonth and annual values that the flow distinguishes
X

Graph View