# Product packages

**Summary:** A product package defines the offer a customer buys. Its starting terms are separate from the plan's recurring terms.

- Role: [Technical Support](https://www.subrite.no/technical-support)
- Source: https://www.subrite.no/technical-support/product-packages
- Updated: 2026-09-28
- Markdown index: https://www.subrite.no/technical-support/llms.txt

A purchased subscription stores its own prices. A difference between the offer shown today and an existing subscription is therefore not enough to establish a pricing error.

The [Administrator guide](https://www.subrite.no/administrators/product-packages?md=true) covers the screen procedures.

<a id="identify-which-price-you-are-explaining"></a>
## Identify which price you are explaining

`PackageProduct.price` stores a starting price for a `packageId`/`productId` pair. `SubscriptionPlanProduct.price` stores the corresponding plan price. `PackageResponse` derives totals from those relationships; its per-product `recurringPrice` is the plan price matched by product ID, or zero if that product is absent from the plan. Changing a product's base price is not the same operation as changing either relationship price. [Package product model](https://github.com/subrite/subrite-crm/blob/932b0744713157826f0441fe1909d96dfede9f9a/api-server/src/modules/product/entities/package-product.entity.ts#L8-L21), [response mapping](https://github.com/subrite/subrite-crm/blob/932b0744713157826f0441fe1909d96dfede9f9a/api-server/src/modules/product/dto/package-response.dto.ts#L46-L81).

There are three different results to distinguish:

| Result | How it is obtained |
| --- | --- |
| Base offer totals | Starting prices summed from package products; recurring prices summed separately from plan products. |
| Totals for selected products | Base nonoptional products plus selected quantities and applicable shipping. |
| Stored subscription values | `MemberPackage.currentPrice`, `MemberPackage.recurringPrice` and its product rows, initialized during purchase processing and changed by later subscription operations. |

<a id="base-offer-versus-selected-quantities"></a>
### Base offer versus selected quantities

Both base sums exclude unpublished products. They include nonoptional products and optional products marked `properties.isRequired` on the respective package or plan relationship. These sums do not add shipping. The returned `products[]` comes from the published package products. [Base sums and publication filters](https://github.com/subrite/subrite-crm/blob/932b0744713157826f0441fe1909d96dfede9f9a/api-server/src/modules/product/dto/package-response.dto.ts#L54-L96).

When `getPackageWithOptionalProducts` receives a nonempty selection array, it replaces those totals:

1. Build `sentProductIds` from submitted rows with truthy (nonzero) quantities. Rows with quantity zero are excluded from this set.
2. Add the starting and recurring base prices of each nonoptional package product outside that set.
3. For each matched selection with a nonzero quantity, add `(starting price + shipping) × quantity` to the starting total and `(recurring price + shipping) × quantity` to the recurring total.

An optional product is included in this calculation only when submitted, even if marked required in the base response. An empty selection array leaves the base totals unchanged. The calculator skips unmatched product IDs; that does not establish that every checkout route accepts such a request. [Selected calculation](https://github.com/subrite/subrite-crm/blob/932b0744713157826f0441fe1909d96dfede9f9a/api-server/src/modules/product/services/package.service.ts#L388-L442).

Within this calculator, submitting a nonoptional product at quantity zero does not remove its base contribution: its ID is outside `sentProductIds`, so step 2 adds it. This describes the calculator's fallback, not a valid API input; the quantity DTO requires an integer of at least 1. [Quantity validation](https://github.com/subrite/subrite-crm/blob/932b0744713157826f0441fe1909d96dfede9f9a/api-server/src/modules/product/dto/optional-product-with-quantity.dto.ts#L46-L63).

Shipping uses the selected country's rate for a physical product. A missing rate or a nonphysical product contributes zero. The free-product exemption applies only when **both** starting and recurring product prices are zero. A zero starting price alone does not waive shipping. [Shipping rules](https://github.com/subrite/subrite-crm/blob/932b0744713157826f0441fe1909d96dfede9f9a/api-server/src/common/utils/order-utility.ts#L232-L273).

<a id="worked-price-example"></a>
### Worked price example

Assume these fictional products are published and all amounts are NOK:

| Product | Starting price | Recurring price | Selection |
| --- | --- | --- | --- |
| Digital access, nonoptional | 99 | 199 | Included in the base. |
| Print, optional and required on both relationships | 40 | 60 | Quantity 2, with shipping 10 per copy. |

The base response is NOK 139 starting and NOK 259 recurring. With the print selection supplied, the calculation becomes:

- Starting: `99 + (40 + 10) × 2 = 199`.
- Recurring: `199 + (60 + 10) × 2 = 339`.

These are source-derived calculations, not observed charges. A package-only physical product would have zero recurring product price, but could still add recurring shipping if its starting price is nonzero. The calculation shown here does not apply an explicit rounding step. [Calculation](https://github.com/subrite/subrite-crm/blob/932b0744713157826f0441fe1909d96dfede9f9a/api-server/src/modules/product/services/package.service.ts#L410-L439).

<a id="what-purchase-processing-stores"></a>
## What purchase processing stores

In the initial-payment-success path, `PurchasePackageTransaction` reloads the payment with its package, plan and product relationships, and obtains selections from `CheckoutState`. It creates a `MemberPackage` with:

- The purchased `packageId` and selected `subscriptionPlanId`.
- `currentPrice` initialized from `Payment.amount`. This is the current-period total, not a permanent first-payment snapshot: `RecurrentPaymentTransaction` later rewrites it from `MemberPackage.recurringPrice`. [Renewal write](https://github.com/subrite/subrite-crm/blob/932b0744713157826f0441fe1909d96dfede9f9a/api-server/src/modules/payment/transactions/recurrent-payment.transaction.ts#L150-L156).
- `autoRenew` from the plan and the initial `nextRenewal` from the package's period.
- Product rows containing starting price, plan recurring price, quantity and shipping country. It recalculates the subscription's final `recurringPrice` from these rows, including shipping.

Gift subscriptions start `WAITING_FOR_ACTIVATION` with `nextRenewal: null`; the ordinary branch starts `ACTIVE`. The order also records `recurringPriceAtOrder`. These stored values explain why today's package response is insufficient evidence for a historical charge. [Purchase writes](https://github.com/subrite/subrite-crm/blob/932b0744713157826f0441fe1909d96dfede9f9a/api-server/src/modules/payment/transactions/purchase-package.transaction.ts#L118-L242), [product-row mapping](https://github.com/subrite/subrite-crm/blob/932b0744713157826f0441fe1909d96dfede9f9a/api-server/src/common/utils/order-utility.ts#L173-L200).

The transaction reads package and plan relationships at processing time. This trace does **not** establish that terms are frozen when checkout first opens. Changes during an already-started checkout need provider-specific validation.

A recurrent event, or a provider update returning no payment to process, exits before these purchase writes. Database failures roll back the transaction's database writes; the package-purchased and checkout-completed events are emitted after commit. This does not mean a database rollback reverses provider money movement or proves downstream delivery. [Early exits and post-commit events](https://github.com/subrite/subrite-crm/blob/932b0744713157826f0441fe1909d96dfede9f9a/api-server/src/modules/payment/transactions/purchase-package.transaction.ts#L80-L115), [database transaction boundary](https://github.com/subrite/subrite-crm/blob/932b0744713157826f0441fe1909d96dfede9f9a/api-server/src/database/application-db/transactions/base-transaction.ts#L22-L44).

<a id="starting-period-and-direct-link-availability"></a>
## Starting period and direct-link availability

The package requires a duration or `introPeriodFixedEndDate`. A duration calculates the end from the starting date; a fixed date gives purchasers the same configured end. The service rejects a fixed date combined with duration fields, or a missing valid alternative. A custom duration needs both unit and frequency. Later recurring periods come from the plan. [Validation](https://github.com/subrite/subrite-crm/blob/932b0744713157826f0441fe1909d96dfede9f9a/api-server/src/modules/product/services/package.service.ts#L92-L118), [period calculation](https://github.com/subrite/subrite-crm/blob/932b0744713157826f0441fe1909d96dfede9f9a/api-server/src/common/utils/billing-period.util.ts#L13-L92).

Direct-link lookup applies these guards in order:

| Guard | Failure | What the result establishes |
| --- | --- | --- |
| A published package matches the supplied tenant and slug. | `packageNotFound`, HTTP 404 | No matching published offer was returned. An unpublished package can produce the same result as an incorrect tenant or slug. |
| `isAvailableViaSlug` is enabled. | `notAvailableViaSlug`, HTTP 404 | The package passed lookup but direct-link sales are disabled. |
| The fixed introduction end date is not before today. | `campaignIsOver`, HTTP 400 | The earlier guards passed, but the fixed date has expired. |

These are API results; checkout renders its own error screens. [Error definitions](https://github.com/subrite/subrite-crm/blob/932b0744713157826f0441fe1909d96dfede9f9a/api-server/src/modules/product/product.errors.ts#L239-L269).

The last guard compares day boundaries in **Europe/Oslo**, using the same `introPeriodFixedEndDate` that controls the starting period. Equality passes: a fixed end of 30 September permits this date check throughout 30 September and fails from 1 October. If the direct-link flag is off on 1 October, `notAvailableViaSlug` wins because it is checked first. A draft package fails the first guard instead. [Lookup and guard order](https://github.com/subrite/subrite-crm/blob/932b0744713157826f0441fe1909d96dfede9f9a/api-server/src/modules/product/services/package.service.ts#L356-L374), [published tenant/slug query](https://github.com/subrite/subrite-crm/blob/932b0744713157826f0441fe1909d96dfede9f9a/api-server/src/modules/product/repositories/package.repository.ts#L198-L210), [day comparison helpers](https://github.com/subrite/subrite-crm/blob/932b0744713157826f0441fe1909d96dfede9f9a/api-server/src/common/utils/date-utility.ts#L103-L112), [default timezone](https://github.com/subrite/subrite-crm/blob/932b0744713157826f0441fe1909d96dfede9f9a/api-server/src/common/constants/settings.constant.ts#L4).

Passing these guards proves neither correct publisher routing nor payment completion. My page and manual admin orders have separate availability flags. The direct-link date guard must not be assumed to apply identically to those routes. [Channel filtering](https://github.com/subrite/subrite-crm/blob/932b0744713157826f0441fe1909d96dfede9f9a/api-server/src/modules/product/services/package.service.ts#L506-L526).

<a id="why-copying-a-link-is-not-a-routing-test"></a>
### Why copying a link is not a routing test

The full address is constructed from the admin request's protocol and host, followed by `/checkout/` and the slug. The observed admin generated an `admin.subrite.no` address; correct publisher routing remains unverified. The copy control inside **Slug** copies only that field; the control beside the full address copies the complete URL. The Administrator guide therefore requires opening the copied address privately and checking the publisher and offer before sharing. [Host construction](https://github.com/subrite/subrite-crm/blob/932b0744713157826f0441fe1909d96dfede9f9a/frontend/src/utils/getSiteHost.ts#L12-L18), [full-address update](https://github.com/subrite/subrite-crm/blob/932b0744713157826f0441fe1909d96dfede9f9a/frontend/src/components/admin/ProductPackages/components/ProductPackageDetails/index.tsx#L613), [copy controls](https://github.com/subrite/subrite-crm/blob/932b0744713157826f0441fe1909d96dfede9f9a/frontend/src/components/admin/ProductPackages/components/PackageSettings/index.tsx#L156-L227).

<a id="what-configuration-changes-affect"></a>
## What configuration changes affect

Selecting **Draft**, **Publish** or **Archive** submits all changed tabs immediately, subject to validation. The update transaction writes package configuration and package-product links and invalidates the package cache. It does not rewrite existing `MemberPackage` records, migrate their plan, charge or refund them, or send subscriber messages. A separate plan or subscription change can have different consequences. [Submit behavior](https://github.com/subrite/subrite-crm/blob/932b0744713157826f0441fe1909d96dfede9f9a/frontend/src/components/admin/ProductPackages/components/ProductPackageSubmitAction/index.tsx#L29-L44), [update transaction](https://github.com/subrite/subrite-crm/blob/932b0744713157826f0441fe1909d96dfede9f9a/api-server/src/modules/product/services/package.service.ts#L192-L302).

Archiving removes the offer from published-package lookup and default published listings. It does not cancel existing subscriptions or refund payments. No guarantee about a checkout already in progress follows from these publication checks.

Deletion first rejects linked subscriptions in `ACTIVE` or `WAITING_FOR_ACTIVATION` with `packageIsActiveAsSubscriptions`. It then checks the preference references `onNewUserCompletedPackageId` and `buyNewSubscriptionPackageId`, returning `packageIsReferencedInSettings` when either is present. Success soft-removes the package and its product links. This handler has no landing-page dependency check, despite the admin help text; no restore control was verified. [Deletion transaction](https://github.com/subrite/subrite-crm/blob/932b0744713157826f0441fe1909d96dfede9f9a/api-server/src/modules/product/services/package.service.ts#L304-L353).

<a id="evidence-limits"></a>
## Evidence limits

Source inspected on 28 September 2026 at `932b0744713157826f0441fe1909d96dfede9f9a`; deployed revision not established. UI observations date from 26 September and used an owner session. The example package stayed draft.

This is a behavior reference. An authorized route for retrieving historical selections and purchase records, including access and retention, has not been validated here. The named records explain what evidence would distinguish offer calculation from stored purchase values; they are not an instruction to query production. Publisher routing, ordinary-role visibility, live error screens and provider behavior during an in-progress checkout remain untested. No payment, repair or entitlement-delivery test was performed.

## Related pages

- [Product packages](https://www.subrite.no/administrators/product-packages?md=true): A product package is the offer a customer buys. It sets the starting price and period, and where the offer is available.
