A Margin-Safe Guide to Configuring Promotions, Discounts & Loyalty Programs in POS
N
Back to Blog

A Margin-Safe Guide to Configuring Promotions, Discounts & Loyalty Programs in POS

Tutorial
Nugroho Setiawan · Konsultan IT & Full Stack Developer 27 Oct 2026 7 min baca 1,564 kata 1 views
A technical guide to designing promotions, tiered discounts, and loyalty programs in POS: data schemas, stacking rules, points ledger, and margin guardrails to keep promotions from eroding profit.

One of the largest sources of margin leakage in retail POS, pharmacies, and restaurants is not cost of goods, but incorrectly calculated promos. A cashier presses a 20% discount even though the requirement is a minimum spend of Rp150,000, a Rp15,000 voucher gets combined with a category discount even though it should be exclusive, and member points accumulate twice because the transaction is resent when the internet connection drops. At one pharmacy chain with 12 branches, 3,500 SKUs, and revenue of Rp2.8 billion per month, a difference of just 1.5% equals Rp42 million per month lost without an audit trail. This article discusses the technical configuration concretely: table structure, priority rules, calculation engine, and an auditable loyalty point ledger.

Data Foundation: Category Hierarchy, Discount Flag, and Promo Table

Before creating even one promo, make sure the master data has a clear hierarchy: department → category → subcategory → SKU. Add a boolean column boleh_diskon to the product table. Without this flag, a promo for the "OBAT" category will also drag in prescription drugs and government-program medicines that by regulation cannot be discounted. In the pharmacy POS implementation we worked on, this filter excluded about 8% of SKUs from all promo calculations and immediately closed the largest leakage gap.

Use the DECIMAL(14,2) data type for all amounts, not FLOAT or DOUBLE. A 12.5% discount on an Rp87,500 transaction yields Rp10,937.50; the POS must consistently round to Rp10,937 using the round half down method, and that rounding rule must be the same at the cashier, on the receipt, and in financial reports. A one-rupiah difference per transaction across 4,000 transactions per day becomes Rp120 million per month that cannot be reconciled.

Separate the promo definition into several tables so it does not become unmanaged boolean columns: promo (code, name, type, value, priority, is_stackable, valid_from, valid_to, jam_mulai, jam_selesai), promo_syarat (minimum spend, category, qty, day, channel), promo_reward (percentage/nominal discount, free item, extra points), and promo_cabang for multi-outlet coverage. When a transaction is opened, the engine only needs one query: fetch active promos for that branch and time, sorted by priority DESC. This structure allows Ramadan promos, member promos, and bundling promos to coexist without overriding each other.

Tiered Discounts, Bundling, and Stacking Rules

There are three patterns clients most often request: percentage discount per item, nominal discount with a minimum spend, and bundling (buy 2 get 1 free or a fixed-price package). For restaurants, bundling must deduct stock per component, not per package. The "Ayam + Nasi + Es Teh" package priced at Rp25,000 from a normal total of Rp32,000 requires a bundle_component table containing component SKUs and qty; if one component is out of stock, the package automatically becomes unavailable on the cashier screen.

The classic problem arises when several promos meet in a single transaction. The safest solution is an exclusive ladder with best-value-first selection: the engine calculates all valid promo combinations, then chooses the combination with the largest total discount for the customer—not following the order of buttons pressed by the cashier. A Rp15,000 voucher marked is_stackable = false automatically cancels the category discount, and the cashier cannot force them to be combined.

// promo-engine.ts — Node 20 + TypeScript 5.4
import { Decimal } from 'decimal.js';
type Item = { sku: string; harga: number; qty: number; kategori: string };

export function hitungDiskon(items: Item[], isMember: boolean): number {
  const subtotal = items.reduce((s, i) => s + i.harga * i.qty, 0);
  const obat = items.filter(i => i.kategori === 'OBAT_BEBAS')
                    .reduce((s, i) => s + i.harga * i.qty, 0);
  const diskonKategori = Math.min(obat * 0.10, 50_000);          // maks Rp50.000
  const diskonMember = isMember ? (subtotal - diskonKategori) * 0.05 : 0; // 5%
  const batas = subtotal * 0.30;                                  // guard rail 30%
  return new Decimal(Math.min(diskonKategori + diskonMember, batas))
           .toDecimalPlaces(0, Decimal.ROUND_HALF_DOWN).toNumber();
}

Note two things in the example above. The member discount is calculated from the remainder after the category discount, so there is no double base—the most common cause of discounts ballooning. Second, there is a 30% guard rail on the subtotal: no matter which promos are active, the total discount never exceeds that limit. The 30% figure is not arbitrary; in a business with a gross margin of 35–40%, a discount above 30% almost always sells below COGS after accounting for shipping costs and the cost of the QRIS payment channel of about 0.7%.

Loyalty Program: Earn Rate, Points Ledger, and Tiers

The first step is to determine the earn rate deliberately, not by habit. If you want to provide an effective 1% cashback, use 1 point per Rp100 with an exchange rate of 1 point = Rp1. If you set 1 point per Rp1,000 with 1 point = Rp1, that is only 0.1%—too small to encourage repeat purchases. Write this ratio in the policy document and lock it in the configuration, because changing it after it has been running will trigger member balance disputes.

Never store point balances as a single column that is updated with UPDATE. Use an append-only ledger: a point_ledger table with columns member_id, tipe (EARN/REDEEM/ADJUST/EXPIRE), delta, ref_transaksi, and created_at. The balance is calculated from SUM(delta), and each row can be traced back to its original receipt. When a member complains that their points are missing, the CS team only needs to open the ledger and find the cause in seconds, not days.

To prevent duplicate points when the connection drops, send each event with an idempotency key in the form of a UUID v7 from the transaction ID. The server rejects the second INSERT with the same key (unique index), so retries from an offline-first POS are safe. This is important for outlets in areas with unstable networks, including our client branches in Magelang and surrounding areas that still rely on mobile connections.

Finally, define tiers with achievable thresholds and reasonable multipliers: Silver 0–999 points per year (1x), Gold 1,000–4,999 (1.25x), Platinum ≥5,000 (1.5x). Run a daily job at 01:00 WIB to execute the expiration of points after 12 months on a FIFO basis per batch, and send an H-30 notification before they expire. FIFO expiration means the oldest points are used first during redemption; without this, your point liability reports will never be accurate.

Best Practices

  1. Implement guard rails at the engine level, not in the UI. The maximum total discount limit (for example, 30%) and minimum margin per transaction must be validated on the server so they cannot be bypassed via the API or client manipulation.
  2. Simulate before publishing. Run the promo in dry-run mode against 100 historical transactions and compare the simulated total discount with the projection. A difference above 2% means there is an incorrect stacking rule.
  3. Require a reason for manual discounts. Every discount outside an official promo must request supervisor authorization (6-digit PIN) and store the user_id, reason, and timestamp in the audit table.
  4. Set a validity period and quota limit per promo. The max_usage and max_usage_per_member columns prevent a viral promo from exhausting the budget in a single day.
  5. Daily reconciliation. Compare the total discount and point redemption in the POS with the ERP journal every day at 23:00; the difference must be zero, not "adjusted next month".

FAQ

How long does it take to implement the promo and loyalty module in an existing POS?

For a POS with a tidy transaction schema, adding a promo engine, point ledger table, and admin configuration screen typically takes 3–5 weeks: 1 week of rule design, 2 weeks of development and migration, and 1–2 weeks of UAT with cashiers and the finance team. If the master data is still messy, allocate an additional 2 weeks specifically for cleaning up categories and discount flags.

How do you prevent cashiers from abusing manual discounts?

Remove free-form input. Replace it with a closed list of reasons (returns, damaged goods, customer complaints), each with a nominal limit. Above that limit, the system asks for a supervisor PIN and records a log. Report a weekly recap of manual discounts per cashier; unusual patterns will become visible within 2–3 reporting cycles.

Can promos differ per branch within one network?

Yes, and they should. Use a promo_cabang table so that a new outlet opening promo in one city does not leak to the entire network. Still provide a network-level promo as the default so you do not have to duplicate the configuration to 12 branches one by one.

How do you audit loyalty points that a member considers lost?

Open the point_ledger for that member and sort by created_at. Every EARN row must have a valid ref_transaksi, and every EXPIRE row must have an expired_batch_id. If a negative balance or a ledger entry without a reference is found, that is an indication of a bug in the redeem process, not a member error.

A good promo, discount, and loyalty configuration always goes hand in hand with a disciplined data schema and guard rails enforced on the server side. Start with tidy discount flags and promo tables, add an engine with explicit stacking rules, then build an auditable point ledger. If your team needs to speed up implementation — from pharmacy, retail, and restaurant POS to ERP and SIM Klinik integration — we offer IT consulting and application development sessions for the Yogyakarta (DIY), Magelang, and Central Java regions. Discuss your needs, and we will map out margin-safe promo rules before a single line of code is written.

This article is part of the knowledge base of Nugroho Setiawan, an IT consultant in Jogja (Yogyakarta). Related solutions: PilarPOS Clinic Management System. Need a free consultation?

Terakhir diperbarui 27 Oct 2026

Komentar

Komentar ditinjau sebelum tampil.

Belum ada komentar. Jadilah yang pertama!

Butuh bantuan implementasi?

Saya membantu konsultasi IT & pengembangan aplikasi untuk rumah sakit, klinik, dan bisnis di Yogyakarta (DIY), Magelang, dan sekitarnya.

Produk & Solusi SIMRS ERP Portfolio Konsultasi Gratis