CheckoutBuild
De sale die zichzelf aanzet: geplande prijzen op Shopify
Diek Thunnissen8 sep 202611 min leestijd
Geen kortingscode, geen sale-app, geen wekker. Een prijs met een begintijd en een eindtijd, die om middernacht zelf wisselt op de pagina, in de feed, overal, en zichzelf daarna terugzet. Een klein app'je op de Admin API, met de code erbij.

Elke Black Friday zit er om 23:55 iemand handmatig prijzen aan te passen in de Shopify-admin. Je kent het ritueel. De sale start om middernacht, dus de avond ervoor zit je klaar. CSV importeren om 23:58. Hopen dat alles doorkomt. En over twee weken hetzelfde nog een keer, maar dan terug.
Dus maakten we van de sale een afspraak: een prijs met een begintijd en een eindtijd. Om middernacht wisselen de prijzen zelf, op de productpagina, op de collectiepagina, in je Google Shopping-feed. Na afloop zet alles zichzelf terug. Jij ligt gewoon te slapen.
De drie manieren waarop het nu meestal gaat
Een kortingscode voor de hele shop. Een sale-app met een eigen dashboard. Of iemand anders de wekker laten zetten. De code-route is de populairste, en ook de zwakste. Niet omdat codes slecht zijn, maar omdat de korting pas bestaat in de winkelwagen en de checkout. Op je collectiepagina staat gewoon de volle prijs. In je feed naar Google Shopping ook. De korting heeft niemand over de streep getrokken op de plek waar mensen beslissen, en dat is niet de checkout.
Een automatische korting in plaats van een code lost een deel op: de klant hoeft niets in te typen. Maar de prijs op de pagina en in de feed blijft de volle prijs, want een korting verandert de prijs van het product niet. Het is een regel die bij het afrekenen wordt toegepast.
Een echte sale is een lagere prijs met een doorstreepprijs erboven. In Shopify heet dat de prijs verlagen en compare-at zetten. Dat is wat een CSV-import om 23:58 doet, en wat een sale-app voor je doet zolang je het abonnement betaalt. We wilden het derde: dat Shopify het zelf doet, op een afgesproken tijdstip, en het daarna netjes terugdraait.
“De hele sale is twee tijdstippen.”
Wat Shopify zelf kan, en wat niet
Eerlijk is eerlijk: op Shopify Plus bestaat Launchpad, en daar kun je een sale inplannen die prijzen aanpast en na afloop terugzet. Zit je op Plus, kijk daar eerst naar. Voor elk ander plan is er niets native dat een prijs op een tijdstip wisselt. Scheduled discounts wel, scheduled prijzen niet.
Daarom bouwden we een klein app'je op de Admin API. Het doet één ding: een campagne met producten of collecties, een kortingstype, een begintijd en een eindtijd. Op de begintijd zet het de prijs omlaag en de oude prijs in compare-at. Op de eindtijd zet het beide exact terug. Geen storefront-script, geen theme-aanpassing, geen kortingscode. De prijs zelf verandert, dus alles wat de prijs leest verandert mee: PDP, collectie, cart, feed, e-mails met productblokken.
Het datamodel: een campagne en een snapshot per variant
Twee tabellen. De campagne heeft een status, een kortingstype met waarde en twee tijdstippen, opgeslagen in UTC en ingevoerd in de tijdzone van de shop. De doelen zijn altijd varianten, ook als je een collectie kiest: die wordt bij het opslaan platgeslagen naar de varianten die er op dat moment in zitten. Zo weet het systeem precies wat het straks terug moet zetten.
// prisma/schema.prisma
model PriceCampaign {
id String @id @default(cuid())
shop String
name String
status String @default("SCHEDULED") // SCHEDULED | ACTIVE | ENDED | CANCELLED | ERROR
discountType String // PERCENTAGE | FIXED_AMOUNT | FIXED_PRICE
discountValue Float
startAt DateTime // stored UTC
endAt DateTime // stored UTC
appliedAt DateTime?
revertedAt DateTime?
lastError String?
// JSON array of { id, title } of collections chosen in the picker. Targets are
// still stored per-variant (collections are flattened at save time); this lets
// the edit form re-populate the collection picker and re-expand on save.
sourceCollections String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
targets PriceCampaignTarget[]
@@index([shop, status])
}
model PriceCampaignTarget {
id String @id @default(cuid())
campaignId String
campaign PriceCampaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
productId String // gid://shopify/Product/... (used to batch the bulk update)
variantId String // gid://shopify/ProductVariant/...
title String? // human-readable label for the UI (product / variant title)
originalPrice String // snapshot, decimal stored as string
originalCompareAt String? // snapshot, may be null
newPrice String? // computed at apply time
applied Boolean @default(false)
targetError String? // per-target failure (e.g. zero/negative price guard)
@@index([campaignId])
@@index([variantId])
}Het belangrijkste veld is originalPrice, plus originalCompareAt. Dat is de snapshot: bij het aanmaken van de campagne leest de app de huidige prijs en compare-at van elke variant en bewaart die. Een snapshot wordt nooit overschreven. Wijzig je de campagne later en voeg je varianten toe, dan krijgen alleen de nieuwe een snapshot en blijven de bestaande staan. Anders gaat het terugzetten mis, en terugzetten is het enige wat er echt toe doet.
De prijsberekening
Drie kortingstypes: een percentage, een vast bedrag eraf, of een vaste nieuwe prijs. De rekensom gebeurt in centen om zwevende-komma-fouten te vermijden (1,005 wordt anders 1,00 in plaats van 1,01). Komt er nul of een negatief bedrag uit, dan wordt die ene variant gemarkeerd en overgeslagen. De rest van de campagne gaat gewoon door.
// app/lib/pricing.ts
export function computeNewPrice(
type: DiscountType,
value: number,
originalPrice: string,
): string {
const original = Number(originalPrice);
if (!Number.isFinite(original) || original < 0) {
throw new PriceError(`Invalid original price: ${originalPrice}`);
}
if (!Number.isFinite(value) || value < 0) {
throw new PriceError(`Invalid discount value: ${value}`);
}
let next: number;
switch (type) {
case "PERCENTAGE":
if (value > 100) {
throw new PriceError(`Percentage discount cannot exceed 100 (got ${value})`);
}
next = original * (1 - value / 100);
break;
case "FIXED_AMOUNT":
next = original - value;
break;
case "FIXED_PRICE":
next = value;
break;
default:
throw new PriceError(`Unknown discount type: ${type}`);
}
const rounded = round2(next);
if (Number(rounded) <= 0) {
throw new PriceError(
`Berekende prijs (${rounded}) is niet groter dan 0, overgeslagen.`,
);
}
return rounded;
}Toepassen: prijs omlaag, origineel in compare-at
Op de begintijd loopt de app de varianten langs, groepeert ze per product en doet per product één productVariantsBulkUpdate. De nieuwe prijs gaat in price, de oorspronkelijke prijs in compareAtPrice. Dat is de hele truc voor de doorstreepprijs: Shopify toont hem zodra compare-at hoger is dan de prijs.
// app/lib/campaign.server.ts
const BULK_UPDATE = `#graphql
mutation ApplyCampaignPrices($productId: ID!, $variants: [ProductVariantsBulkInput!]!) {
productVariantsBulkUpdate(productId: $productId, variants: $variants) {
product { id }
productVariants { id price compareAtPrice }
userErrors { field message }
}
}`;// app/lib/campaign.server.ts, kern van applyCampaign (stap 1, de prijsberekening per variant, is hierboven)
// 2. One bulk update per product, bounded concurrency, then mark applied.
const groups = [...groupByProduct(toApply).entries()];
await mapWithConcurrency(groups, CONCURRENCY, async ([productId, targets]) => {
const variants: BulkVariantInput[] = targets.map((t) => ({
id: t.variantId,
price: t.newPrice!,
compareAtPrice: t.originalPrice, // makes the "was/now" strikethrough show
metafields: [
{
namespace: "timed_price",
key: "original_price",
type: "number_decimal",
value: t.originalPrice,
},
],
}));
await runBulkUpdate(admin, productId, variants);
await prisma.priceCampaignTarget.updateMany({
where: { id: { in: targets.map((t) => t.id) } },
data: { applied: true },
});
});
}Twee dingen vallen op. Ten eerste schrijft de app tegelijk een metafield timed_price.original_price op de variant. Dat is een vangnet: mocht de database van de app ooit weg zijn, dan staat de oorspronkelijke prijs nog in Shopify zelf. Ten tweede wordt elke variant na een geslaagde update als applied gemarkeerd. Draait het toepassen per ongeluk twee keer, dan gebeurt er de tweede keer niets.
Terugzetten: exact, ook als compare-at leeg was
Op de eindtijd gebeurt het omgekeerde, alleen voor varianten die daadwerkelijk zijn toegepast. De prijs gaat terug naar de snapshot en compare-at naar wat het was. Dat kan null zijn, en dan wordt hij ook echt leeggemaakt. Een sale-app die na afloop de oude prijs in compare-at laat staan, laat je met een permanente nepkorting achter. Dat is precies waar de 30-dagenregel verderop over gaat.
// app/lib/campaign.server.ts
export async function revertCampaign(
admin: AdminClient,
campaign: PriceCampaign & { targets: PriceCampaignTarget[] },
): Promise<void> {
const applied = campaign.targets.filter((t) => t.applied);
const groups = [...groupByProduct(applied).entries()];
await mapWithConcurrency(groups, CONCURRENCY, async ([productId, targets]) => {
const variants: BulkVariantInput[] = targets.map((t) => ({
id: t.variantId,
price: t.originalPrice,
compareAtPrice: t.originalCompareAt, // restore exact original (may be null)
}));
await runBulkUpdate(admin, productId, variants);
await prisma.priceCampaignTarget.updateMany({
where: { id: { in: targets.map((t) => t.id) } },
data: { applied: false },
});
});
}De klok: één tick per minuut
Er is geen wachtrij en geen wekker. Elke minuut draait één functie die twee vragen stelt: welke campagnes zijn ingepland en hadden al moeten starten, en welke zijn actief en hadden al moeten stoppen. Beide lijsten worden afgewerkt, elk in een eigen try/catch, zodat één mislukte campagne de andere nooit blokkeert.
52 regels codeToon code +Verberg code −
// app/lib/scheduler.server.ts
export async function tick(now: Date = new Date()): Promise<TickResult> {
const result: TickResult = { applied: [], reverted: [], errored: [] };
const due = await prisma.priceCampaign.findMany({
where: { status: "SCHEDULED", startAt: { lte: now } },
include: { targets: true },
});
for (const campaign of due) {
try {
const admin = await adminForShop(campaign.shop);
await applyCampaign(admin, campaign);
await prisma.priceCampaign.update({
where: { id: campaign.id },
data: { status: "ACTIVE", appliedAt: now, lastError: null },
});
result.applied.push(campaign.id);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
await prisma.priceCampaign.update({
where: { id: campaign.id },
data: { status: "ERROR", lastError: message },
});
result.errored.push({ id: campaign.id, error: message });
}
}
const expired = await prisma.priceCampaign.findMany({
where: { status: "ACTIVE", endAt: { lte: now } },
include: { targets: true },
});
for (const campaign of expired) {
try {
const admin = await adminForShop(campaign.shop);
await revertCampaign(admin, campaign);
await prisma.priceCampaign.update({
where: { id: campaign.id },
data: { status: "ENDED", revertedAt: now, lastError: null },
});
result.reverted.push(campaign.id);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
await prisma.priceCampaign.update({
where: { id: campaign.id },
data: { status: "ERROR", lastError: message },
});
result.errored.push({ id: campaign.id, error: message });
}
}
return result;
}Lokaal draait dit als een cron in het proces zelf. In productie roept een externe cron een beveiligde route aan die dezelfde tick uitvoert. Beide kanten roepen letterlijk dezelfde functie aan, dus wat je in dev test is wat er 's nachts draait.
De valkuilen
Prijs tussendoor handmatig gewijzigd. Iemand past tijdens de sale een prijs aan in de admin. De app weet dat niet en zet aan het eind de snapshot terug, dus de handmatige wijziging is weg. Dat is een keuze: de snapshot wint, want die is het enige waarvan zeker is dat hij klopte. Wil je een prijs structureel veranderen, doe dat vóór of ná de campagne.
Overlappende campagnes. Twee campagnes op dezelfde variant in dezelfde periode zijn geblokkeerd bij het aanmaken. Anders heeft de tweede een snapshot van een prijs die al verlaagd was, en zet hij aan het eind een sale-prijs terug als "origineel". Stapelen klinkt handig en is de snelste route naar een verkeerde prijs op je site.
Compare-at die er al stond. Sommige producten hebben al een doorstreepprijs, bijvoorbeeld een adviesprijs. De app overschrijft compare-at met de prijs van vóór de sale en zet daarna de oude compare-at terug. Tijdens de sale zie je dus "was 49, nu 39" en niet de adviesprijs. Meestal is dat wat je wilt. Niet altijd.
Markets. De app werkt in de basisvaluta van de shop. Marktprijzen die Shopify afleidt via een wisselkoers gaan automatisch mee omlaag. Vaste prijzen per markt uit een prijslijst niet, die blijven staan. Verkoop je met eigen prijzen per land, dan moet de campagne ook die prijslijsten aanpakken, en dat doet deze versie niet.
Grote catalogi. Per product één update, met vijf tegelijk en een backoff als Shopify de rem erop zet. Voor honderden varianten is dat ruim voldoende. Voor tienduizenden wil je de bulk-operations van de Admin API, en dat is bewust buiten deze eerste versie gehouden.
De-installatie. Wie de app verwijdert terwijl een campagne loopt, blijft anders met sale-prijzen zitten. De uninstall-webhook probeert daarom eerst alle actieve campagnes terug te zetten. Het token kan op dat moment al ingetrokken zijn, dus het is een poging, geen garantie. De snapshot blijft in de database staan voor handmatig herstel, en het metafield op de variant ook.
De doorstreepprijs moet kloppen
Een geplande sale verandert niets aan de regels. In Nederland moet een doorstreepprijs de laagste prijs zijn die je in de dertig dagen ervoor hebt gerekend, ook als de sale automatisch aangaat. Dat is de Omnibus-regel zoals die in het Prijsaanduidingsbesluit staat, en de ACM handhaaft erop.
Het goede nieuws is dat een systeem dat prijzen plant, ook precies weet welke prijs wanneer gold. De snapshot en de tijdstippen zijn je bewijs. Dat is een stuk beter dan gokken wat er dertig dagen geleden in de CSV stond. Maar het systeem controleert de regel niet voor je. Als je twee weken voor Black Friday een prijs verhoogt om hem daarna te "verlagen", helpt geen enkele planner je.
Je hoeft niet groot te beginnen. Eén collectie inplannen is genoeg om het ritueel van 23:55 te breken. Outlet-vrijdag, de maandaanbieding, de seizoenswissel: één keer instellen in plaats van elke keer handwerk.
Veelgestelde vragen.
- Kan dit niet gewoon met een automatische korting?
- Een automatische korting rekent pas af in de winkelwagen en de checkout. Je collectiepagina, productpagina en Google Shopping-feed tonen de volle prijs. Voor een zichtbare sale moet de echte prijs omlaag, met de oude prijs als doorstreepprijs in compare-at. Dat is wat deze app doet.
- Heb je hier Shopify Plus voor nodig?
- Nee. De app werkt op de Admin API die elk plan heeft. Op Plus kun je ook Launchpad gebruiken, dat prijzen kan inplannen en terugzetten. Zit je op Plus, dan is dat de eerste plek om te kijken. Voor alle andere plannen is er geen native alternatief voor geplande prijswijzigingen.
- Wat gebeurt er als de sale loopt en iemand een prijs handmatig aanpast?
- De app zet aan het eind van de campagne de snapshot terug, dus de handmatige wijziging verdwijnt. De snapshot is bewust leidend, omdat dat de enige prijs is waarvan vaststaat dat hij klopte. Structurele prijswijzigingen doe je vóór of ná een campagne, niet tijdens.
Diek ThunnissenFounder & lead developer. Bouwt, verbetert en migreert de Shopify-laag voor DTC- en B2B-merken. LinkedInStuur me je checkout →

