diff --git a/src/lib/offline/order-wizard-data.ts b/src/lib/offline/order-wizard-data.ts index 44f3f918..cbba0799 100644 --- a/src/lib/offline/order-wizard-data.ts +++ b/src/lib/offline/order-wizard-data.ts @@ -66,6 +66,9 @@ export type WizardAccount = { state: string | null; zip: string | null; payment_preference: string | null; + /** Optional: snapshots cached before SCO-137 don't carry these. */ + organization_id?: string; + order_minimum_override?: number | string | null; }; export type WizardLocation = { diff --git a/src/lib/server/ai-tools.ts b/src/lib/server/ai-tools.ts index 3aa3e6d9..c923a960 100644 --- a/src/lib/server/ai-tools.ts +++ b/src/lib/server/ai-tools.ts @@ -1,6 +1,7 @@ import type { SupabaseClient } from '@supabase/supabase-js'; import { getGmailClient, parseMessage, buildRawEmail } from './gmail.js'; import { supabaseAdmin } from './supabase.js'; +import { findMinimumViolations, blockingViolations } from './orders/order-minimum.js'; import { checkFilterColumns, omitForEntity } from './ai-query-columns.js'; import { FULFILLMENT_STATUSES, @@ -617,8 +618,24 @@ async function createOrder( }; } + // Brand minimum (SCO-137): under the minimum the order stays a draft, unless + // the brand's own org is placing it. The reason goes back to the model so it + // can tell the user why. + let minimumReason: string | null = null; + if (finalStatus === 'submitted' && orderType === 'order') { + const total = lineRows.reduce((sum, r) => sum + r.qty * r.unit_price, 0); + const [block] = blockingViolations( + await findMinimumViolations( + supabaseAdmin, + [{ brand_id: brand.id, account_id: account.id, total_amount: total }], + ctx.organizationId + ) + ); + minimumReason = block?.message ?? null; + } + // --- Promote to submitted (unless the caller asked for draft) --- - if (finalStatus === 'submitted') { + if (finalStatus === 'submitted' && !minimumReason) { const { error: statusErr } = await ctx.supabase .from('orders') .update({ status: 'submitted', submitted_at: new Date().toISOString() }) @@ -668,7 +685,14 @@ async function createOrder( 'seasons' ] }); - return { success: true, data: { ...trimmed, lines_added: lineRows.length } }; + return { + success: true, + data: { + ...trimmed, + lines_added: lineRows.length, + ...(minimumReason ? { saved_as_draft_reason: minimumReason } : {}) + } + }; } // Fire the same email + in-app notifications that /orders/new does on submit. diff --git a/src/lib/server/email-intake/outcome.ts b/src/lib/server/email-intake/outcome.ts index ac56c07c..368f19bf 100644 --- a/src/lib/server/email-intake/outcome.ts +++ b/src/lib/server/email-intake/outcome.ts @@ -2,6 +2,7 @@ import { supabaseAdmin } from '$lib/server/supabase.js'; import type { ResolvedOrder, ResolvedLine } from './resolve.js'; import { THRESHOLDS } from './resolve.js'; import type { SenderAuthVerdict } from './authentication.js'; +import { findMinimumViolations, blockingViolations } from '$lib/server/orders/order-minimum.js'; // ── Types ──────────────────────────────────────────────────── @@ -85,6 +86,35 @@ export function decideOutcome( // ── Execution ──────────────────────────────────────────────── +/** + * Hold an auto-submit for review when the order is under its brand's minimum + * (SCO-137). The order still lands, as a draft, so the brand can approve it. + * The intake's own org is the actor: a brand receiving its own orders may + * submit under its minimum, so only a rep's intake is held. + */ +export async function applyOrderMinimum( + resolved: ResolvedOrder & { kind: 'resolved' }, + outcome: Outcome +): Promise { + if (outcome.status !== 'submitted' || !resolved.brandId) return outcome; + const total = resolved.lines.reduce( + (sum, line) => sum + line.variants.reduce((s, v) => s + v.qty * v.unitPrice, 0), + 0 + ); + const [block] = blockingViolations( + await findMinimumViolations( + supabaseAdmin, + [{ brand_id: resolved.brandId, account_id: resolved.accountId, total_amount: total }], + resolved.organizationId + ) + ); + if (!block) return outcome; + return { + status: 'needs_review', + reasons: [...outcome.reasons, { lineIndex: null, reason: block.message }] + }; +} + /** * Execute the outcome: create the order and update the intake record. */ diff --git a/src/lib/server/orders/load-order-prereqs.ts b/src/lib/server/orders/load-order-prereqs.ts index 25bea5da..43b5e2ae 100644 --- a/src/lib/server/orders/load-order-prereqs.ts +++ b/src/lib/server/orders/load-order-prereqs.ts @@ -73,7 +73,7 @@ export async function loadOrderPrereqs(locals: App.Locals) { let q = supabaseAdmin .from('accounts') .select( - 'id, business_name, contact_email, address_line1, address_line2, city, state, zip, payment_preference, payment_terms, shipping_method' + 'id, organization_id, business_name, contact_email, address_line1, address_line2, city, state, zip, payment_preference, payment_terms, shipping_method, order_minimum_override' ); if (buyerAccountIds) q = q.in('id', buyerAccountIds.length ? buyerAccountIds : ['__none__']); else q = q.in('organization_id', visibleOrgIds).eq('is_active', true).is('archived_at', null); diff --git a/src/lib/server/orders/order-minimum.test.ts b/src/lib/server/orders/order-minimum.test.ts new file mode 100644 index 00000000..6ac553af --- /dev/null +++ b/src/lib/server/orders/order-minimum.test.ts @@ -0,0 +1,116 @@ +import { describe, it, expect } from 'vitest'; +import type { SupabaseClient } from '@supabase/supabase-js'; +import { findMinimumViolations, blockingViolations } from './order-minimum'; +import type { BrandCommerceSettings } from './resolve-order-settings'; + +function settingsFor( + brandId: string, + ownerOrgId: string, + amount: number | null +): BrandCommerceSettings { + return { + brand_id: brandId, + owner_org_id: ownerOrgId, + order_minimum_enabled: amount !== null, + order_minimum_amount: amount + } as BrandCommerceSettings; +} + +// Minimal stand-in for the two lookups the helper makes. +function fakeClient(tables: Record>>): SupabaseClient { + return { + from(table: string) { + return { + select() { + return { + in(_col: string, ids: string[]) { + const rows = (tables[table] ?? []).filter((r) => ids.includes(r.id as string)); + return Promise.resolve({ data: rows, error: null }); + } + }; + } + }; + } + } as unknown as SupabaseClient; +} + +const client = fakeClient({ + brands: [{ id: 'b1', name: 'Acme' }], + accounts: [ + { id: 'own-acct', organization_id: 'brand-org', order_minimum_override: 100 }, + { id: 'rep-acct', organization_id: 'rep-org', order_minimum_override: 0 } + ] +}); +const settings = new Map([['b1', settingsFor('b1', 'brand-org', 500)]]); + +describe('findMinimumViolations', () => { + it('blocks a rep under the minimum, naming the brand', async () => { + const v = await findMinimumViolations( + client, + [{ brand_id: 'b1', account_id: 'rep-acct', total_amount: 380 }], + 'rep-org', + settings + ); + expect(v).toHaveLength(1); + expect(v[0]).toMatchObject({ shortfall: 120, minimum: 500, canOverride: false }); + expect(v[0].message).toContain("Acme's $500.00 minimum"); + expect(blockingViolations(v)).toHaveLength(1); + }); + + it('lets the brand override its own minimum', async () => { + const v = await findMinimumViolations( + client, + [{ brand_id: 'b1', account_id: 'rep-acct', total_amount: '380.00' }], + 'brand-org', + settings + ); + expect(v[0].canOverride).toBe(true); + expect(blockingViolations(v)).toHaveLength(0); + }); + + it('never lets a null actor (a buyer) override', async () => { + const v = await findMinimumViolations( + client, + [{ brand_id: 'b1', account_id: null, total_amount: 10 }], + null, + settings + ); + expect(blockingViolations(v)).toHaveLength(1); + }); + + it("honors the brand's own account override", async () => { + const v = await findMinimumViolations( + client, + [{ brand_id: 'b1', account_id: 'own-acct', total_amount: 150 }], + 'rep-org', + settings + ); + expect(v).toHaveLength(0); + }); + + it('passes orders at or over the minimum and brands with none', async () => { + const v = await findMinimumViolations( + client, + [ + { brand_id: 'b1', account_id: null, total_amount: 500 }, + { brand_id: 'b2', account_id: null, total_amount: 1 } + ], + 'rep-org', + new Map([...settings, ['b2', settingsFor('b2', 'other-org', null)]]) + ); + expect(v).toHaveLength(0); + }); + + it('reports the index of each failing order', async () => { + const v = await findMinimumViolations( + client, + [ + { brand_id: 'b1', account_id: null, total_amount: 900 }, + { brand_id: 'b1', account_id: null, total_amount: 10 } + ], + 'rep-org', + settings + ); + expect(v.map((x) => x.index)).toEqual([1]); + }); +}); diff --git a/src/lib/server/orders/order-minimum.ts b/src/lib/server/orders/order-minimum.ts new file mode 100644 index 00000000..a7a55920 --- /dev/null +++ b/src/lib/server/orders/order-minimum.ts @@ -0,0 +1,103 @@ +import type { SupabaseClient } from '@supabase/supabase-js'; +import { resolveOrderSettings, type BrandCommerceSettings } from './resolve-order-settings.js'; +import { + effectiveOrderMinimum, + minimumBlockedMessage, + minimumOverrideNote, + minimumShortfall, + type MinimumAccount +} from '$lib/utils/order-minimum'; + +export type MinimumCheckOrder = { + brand_id: string; + account_id: string | null; + total_amount: number | string | null; +}; + +export type MinimumViolation = { + /** Index into the orders passed in. */ + index: number; + brand_id: string; + brand_name: string; + minimum: number; + shortfall: number; + /** The actor's org owns the brand, so it may submit anyway. */ + canOverride: boolean; + /** What to show the actor: a block for others, a note for the brand. */ + message: string; +}; + +/** + * Which of these orders sit under their brand's minimum (SCO-137). + * + * Takes the caller's client rather than naming one: every call site has + * already authorized the order, and some run as the service role. Pass + * `settings` when the caller has already resolved them. + */ +export async function findMinimumViolations( + client: SupabaseClient, + orders: MinimumCheckOrder[], + /** + * The acting org, or null when the actor can never override. Pass null for + * a buyer: a legacy buyer's `locals.organization` is the brand's own org, + * and a buyer must not inherit the brand's override. + */ + actorOrgId: string | null, + settings?: Map +): Promise { + if (orders.length === 0) return []; + const brandIds = Array.from(new Set(orders.map((o) => o.brand_id))); + const resolved = settings ?? (await resolveOrderSettings(client, brandIds)); + + const accountIds = Array.from( + new Set(orders.map((o) => o.account_id).filter((id): id is string => Boolean(id))) + ); + const [{ data: accountRows }, { data: brandRows }] = await Promise.all([ + accountIds.length + ? client + .from('accounts') + .select('id, organization_id, order_minimum_override') + .in('id', accountIds) + : Promise.resolve({ data: [] }), + client.from('brands').select('id, name').in('id', brandIds) + ]); + const accounts = new Map( + ((accountRows ?? []) as Array<{ id: string } & NonNullable>).map((a) => [ + a.id, + a + ]) + ); + const brandNames = new Map( + ((brandRows ?? []) as Array<{ id: string; name: string }>).map((b) => [b.id, b.name]) + ); + + const violations: MinimumViolation[] = []; + orders.forEach((order, index) => { + const brandSettings = resolved.get(order.brand_id); + if (!brandSettings) return; + const account = order.account_id ? (accounts.get(order.account_id) ?? null) : null; + const minimum = effectiveOrderMinimum(brandSettings, account); + const shortfall = minimumShortfall(Number(order.total_amount ?? 0), minimum); + if (minimum === null || shortfall === null) return; + + const canOverride = actorOrgId !== null && actorOrgId === brandSettings.owner_org_id; + const brandName = brandNames.get(order.brand_id) ?? 'the brand'; + violations.push({ + index, + brand_id: order.brand_id, + brand_name: brandName, + minimum, + shortfall, + canOverride, + message: canOverride + ? minimumOverrideNote(minimum, shortfall) + : minimumBlockedMessage(brandName, minimum, shortfall) + }); + }); + return violations; +} + +/** The violations that stop the actor from submitting. */ +export function blockingViolations(violations: MinimumViolation[]): MinimumViolation[] { + return violations.filter((v) => !v.canOverride); +} diff --git a/src/lib/server/orders/submit-order.test.ts b/src/lib/server/orders/submit-order.test.ts index 8bf71e3c..e72f1f5d 100644 --- a/src/lib/server/orders/submit-order.test.ts +++ b/src/lib/server/orders/submit-order.test.ts @@ -49,6 +49,22 @@ vi.mock('$lib/server/orders/resolve-order-settings.js', () => ({ ) })); +// Minimum lookups are covered in order-minimum.test.ts; here each test says +// what the lookup returns. The default is no violations. +type Violation = { + index: number; + canOverride: boolean; + message: string; + brand_name?: string; + minimum?: number; + shortfall?: number; +}; +const findMinimumViolations = vi.fn<(...args: unknown[]) => Promise>(async () => []); +vi.mock('$lib/server/orders/order-minimum.js', () => ({ + findMinimumViolations, + blockingViolations: (v: Violation[]) => v.filter((x) => !x.canOverride) +})); + const { submitOrder, submitOrderPayload } = await import('./submit-order'); const { uuidV5 } = await import('./order-ids'); @@ -96,6 +112,8 @@ async function run(req: Request, l: App.Locals) { } beforeEach(() => { + findMinimumViolations.mockReset(); + findMinimumViolations.mockResolvedValue([]); rpc.mockReset(); sendOrderEmail.mockReset(); notifyBrandAdmins.mockReset(); @@ -350,3 +368,120 @@ describe('drift on captured orders (SCO-209)', () => { expect(createNotification).not.toHaveBeenCalled(); }); }); + +describe('brand minimum (SCO-137)', () => { + const UNDER = + "This order is $480.00 under Acme's $500.00 minimum. Save it as a draft and Acme can approve it."; + const payload = (over: Record = {}) => ({ + submission_id: SUBMISSION, + type: 'order', + account_id: ACCOUNT, + freeform_name: null, + order_year: 2026, + submitStatus: 'submitted', + payment_preference: null, + lines: [ + { product_id: PRODUCT, brand_id: BRAND, season_id: null, color: 'Black', size: 'M', qty: 2 } + ], + groups: [{ brand_id: BRAND, season_id: null, delivery: null, location_id: null }], + ...over + }); + const written = { + data: [{ order_id: orderId, order_number: 'A-1', inserted: true }], + error: null + }; + + beforeEach(() => createNotification.mockClear()); + + it('rejects an online submit under the minimum and writes nothing', async () => { + findMinimumViolations.mockResolvedValue([ + { + index: 0, + canOverride: false, + message: UNDER, + brand_name: 'Acme', + minimum: 500, + shortfall: 480 + } + ]); + const out = await submitOrderPayload(payload(), locals(), ''); + expect(out).toEqual({ + ok: false, + status: 400, + message: `${UNDER} Save it as a draft for Acme to review.` + }); + expect(rpc).not.toHaveBeenCalled(); + }); + + it('saves a captured order under the minimum as a draft and tells the rep', async () => { + findMinimumViolations.mockResolvedValue([ + { + index: 0, + canOverride: false, + message: UNDER, + brand_name: 'Acme', + minimum: 500, + shortfall: 480 + } + ]); + rpc.mockResolvedValue(written); + const out = await submitOrderPayload(payload(), locals(), '', { captured: true }); + expect(rpc.mock.calls[0][1].p_orders[0]).toMatchObject({ status: 'draft', submitted_at: null }); + expect(out).toMatchObject({ + ok: true, + drift: { reason: `${UNDER} Save it as a draft for Acme to review.` } + }); + expect(createNotification).toHaveBeenCalledTimes(1); + expect(sendOrderEmail).not.toHaveBeenCalled(); + }); + + it("lets the brand's own org submit under its minimum", async () => { + findMinimumViolations.mockResolvedValue([{ index: 0, canOverride: true, message: 'note' }]); + rpc.mockResolvedValue(written); + const out = await submitOrderPayload(payload(), locals(), ''); + expect(out).toMatchObject({ ok: true }); + expect(rpc.mock.calls[0][1].p_orders[0].status).toBe('submitted'); + }); + + it('does not check drafts or notes', async () => { + rpc.mockResolvedValue(written); + await submitOrderPayload(payload({ submitStatus: 'draft' }), locals(), ''); + await submitOrderPayload(payload({ type: 'note' }), locals(), ''); + for (const call of findMinimumViolations.mock.calls) expect(call[1]).toEqual([]); + }); + + it('tells a buyer what to add instead of offering a draft', async () => { + findMinimumViolations.mockResolvedValue([ + { + index: 0, + canOverride: false, + message: UNDER, + brand_name: 'Acme', + minimum: 500, + shortfall: 480 + } + ]); + const buyer = { ...locals(), isBuyer: true, membership: null } as unknown as App.Locals; + const out = await submitOrderPayload(payload(), buyer, ''); + expect(out).toEqual({ + ok: false, + status: 400, + message: "Add $480.00 to reach Acme's $500.00 minimum." + }); + }); + + it('checks a buyer with no override, even inside the brand org', async () => { + rpc.mockResolvedValue(written); + const buyer = { ...locals(), isBuyer: true, membership: null } as unknown as App.Locals; + await submitOrderPayload(payload(), buyer, ''); + expect(findMinimumViolations.mock.calls[0][2]).toBeNull(); + }); + + it('checks submitted orders as the acting org', async () => { + rpc.mockResolvedValue(written); + await submitOrderPayload(payload(), locals(), ''); + const [, orders, actorOrgId] = findMinimumViolations.mock.calls[0]; + expect(orders).toEqual([{ brand_id: BRAND, account_id: ACCOUNT, total_amount: 20 }]); + expect(actorOrgId).toBe(ORG); + }); +}); diff --git a/src/lib/server/orders/submit-order.ts b/src/lib/server/orders/submit-order.ts index 7b40f81b..d169afa2 100644 --- a/src/lib/server/orders/submit-order.ts +++ b/src/lib/server/orders/submit-order.ts @@ -18,6 +18,8 @@ import { resolveOrderSettings } from '$lib/server/orders/resolve-order-settings. import { resolveOrderScope } from '$lib/server/orders/order-scope.js'; import { authorizeSubmitPayload } from '$lib/server/orders/authorize-submit.js'; import { submissionIds } from '$lib/server/orders/order-ids.js'; +import { findMinimumViolations, blockingViolations } from '$lib/server/orders/order-minimum.js'; +import { minimumBuyerMessage, minimumDraftHint } from '$lib/utils/order-minimum'; export type SubmitOutcome = | { @@ -267,6 +269,40 @@ export async function submitOrderPayload( return { ok: false, status: 500, message: 'Failed to resolve brand commerce settings' }; } + // Brand minimums (SCO-137). Notes and drafts are exempt: a note is not an + // order yet, and a draft is where an under-minimum order waits for the brand. + // The brand's own org may submit under its minimum, so a brand-org writer + // never trips this. An order captured offline is saved as a draft instead of + // rejected, so nothing the rep wrote is lost. + const submitting = newOrders.filter((o) => o.order_type !== 'note' && o.status !== 'draft'); + const minimumBlocks = blockingViolations( + await findMinimumViolations( + supabase, + submitting.map((o) => ({ + brand_id: o.brand_id, + account_id: o.account_id, + total_amount: o.total_amount + })), + // A buyer never overrides, even when its org is the brand's. + locals.isBuyer ? null : organization.id, + brandCommerce + ) + ); + let minimumReason: string | null = null; + if (minimumBlocks.length > 0) { + // A buyer at checkout has no draft to save, so it is told what to add. + const message = minimumBlocks + .map((v) => + locals.isBuyer + ? minimumBuyerMessage(v.brand_name, v.minimum, v.shortfall) + : `${v.message} ${minimumDraftHint(v.brand_name)}` + ) + .join(' '); + if (!opts.captured) return { ok: false, status: 400, message }; + for (const v of minimumBlocks) submitting[v.index].status = 'draft'; + minimumReason = message; + } + const brandOrgMap = new Map(); if (isBrandOrgWriter) { for (const [brandId, settings] of brandCommerce) { @@ -487,8 +523,9 @@ export async function submitOrderPayload( } } - if (drift) { - const reason = driftReason(drift); + const reviewReason = [drift ? driftReason(drift) : null, minimumReason].filter(Boolean).join(' '); + if (reviewReason) { + const reason = reviewReason; const first = orders.find((o) => o.inserted); // Once, on the first write; a replay already told the rep. if (first) { diff --git a/src/lib/utils/order-minimum.test.ts b/src/lib/utils/order-minimum.test.ts new file mode 100644 index 00000000..98cc23f5 --- /dev/null +++ b/src/lib/utils/order-minimum.test.ts @@ -0,0 +1,90 @@ +import { describe, it, expect } from 'vitest'; +import { + effectiveOrderMinimum, + minimumShortfall, + minimumBlockedMessage, + minimumBuyerMessage, + minimumDraftHint, + minimumOverrideNote +} from './order-minimum'; + +const brand = (enabled: boolean, amount: number | null) => ({ + order_minimum_enabled: enabled, + order_minimum_amount: amount, + owner_org_id: 'brand-org' +}); + +describe('effectiveOrderMinimum', () => { + it('uses the brand minimum when enabled', () => { + expect(effectiveOrderMinimum(brand(true, 500), null)).toBe(500); + }); + + it('is null when the brand minimum is off, even with an amount saved', () => { + expect(effectiveOrderMinimum(brand(false, 500), null)).toBeNull(); + }); + + it('is null for a zero or missing amount', () => { + expect(effectiveOrderMinimum(brand(true, 0), null)).toBeNull(); + expect(effectiveOrderMinimum(brand(true, null), null)).toBeNull(); + }); + + it("applies an override on the brand's own account", () => { + const account = { organization_id: 'brand-org', order_minimum_override: '250' }; + expect(effectiveOrderMinimum(brand(true, 500), account)).toBe(250); + }); + + it('treats an override of 0 as no minimum for that account', () => { + const account = { organization_id: 'brand-org', order_minimum_override: 0 }; + expect(effectiveOrderMinimum(brand(true, 500), account)).toBeNull(); + }); + + it("ignores an override on an account another org owns (a rep's account)", () => { + const account = { organization_id: 'rep-org', order_minimum_override: 0 }; + expect(effectiveOrderMinimum(brand(true, 500), account)).toBe(500); + }); + + it('ignores a null override', () => { + const account = { organization_id: 'brand-org', order_minimum_override: null }; + expect(effectiveOrderMinimum(brand(true, 500), account)).toBe(500); + }); +}); + +describe('minimumShortfall', () => { + it('returns the gap when under', () => { + expect(minimumShortfall(380, 500)).toBe(120); + }); + + it('is null at or over the minimum', () => { + expect(minimumShortfall(500, 500)).toBeNull(); + expect(minimumShortfall(612.5, 500)).toBeNull(); + }); + + it('is null with no minimum', () => { + expect(minimumShortfall(10, null)).toBeNull(); + }); + + it('rounds to cents so float noise never blocks an order', () => { + expect(minimumShortfall(0.1 + 0.2, 0.3)).toBeNull(); + }); +}); + +describe('messages', () => { + it('names the shortfall, the minimum and the brand', () => { + expect(minimumBlockedMessage('Acme', 500, 120)).toBe( + "This order is $120.00 under Acme's $500.00 minimum. Only Acme can submit it." + ); + expect(minimumDraftHint('Acme')).toBe('Save it as a draft for Acme to review.'); + }); + + it('tells a buyer how much to add', () => { + expect(minimumBuyerMessage('Acme', 500, 120)).toBe( + "Add $120.00 to reach Acme's $500.00 minimum." + ); + }); + + it('tells the brand it can still submit', () => { + expect(minimumOverrideNote(500, 120)).toBe( + 'This order is $120.00 under your $500.00 minimum. You can still submit it.' + ); + }); +}); diff --git a/src/lib/utils/order-minimum.ts b/src/lib/utils/order-minimum.ts new file mode 100644 index 00000000..0cb04af0 --- /dev/null +++ b/src/lib/utils/order-minimum.ts @@ -0,0 +1,83 @@ +/** + * Brand order minimums (SCO-137). + * + * An order under its brand's minimum can be saved as a draft but not + * submitted, except by the org that owns the brand: the brand can accept an + * under-minimum order and submit it anyway. Every submit path on the server + * checks this through `findMinimumViolations`; the wizard and checkout use the + * same rule to warn before the rep or buyer gets there. + * + * Pure, so the client and the server share it. + */ + +export type MinimumSettings = { + order_minimum_enabled: boolean; + order_minimum_amount: number | null; + /** The org that owns the brand, which is the org allowed to override. */ + owner_org_id: string; +}; + +export type MinimumAccount = { + organization_id: string; + order_minimum_override: number | string | null; +} | null; + +/** + * The minimum that applies to one order, or null when there is none. + * + * An account override replaces the brand's minimum ("Block orders under this + * amount unless an account override applies"), but only on an account the + * brand's own org owns. A rep's account lives in the rep's org, and letting it + * set its own override would let a rep lower a connected brand's minimum. + * An override of 0 means no minimum for that account. + */ +export function effectiveOrderMinimum( + settings: MinimumSettings, + account: MinimumAccount +): number | null { + if ( + account && + account.organization_id === settings.owner_org_id && + account.order_minimum_override !== null && + account.order_minimum_override !== undefined + ) { + const override = Number(account.order_minimum_override); + if (Number.isFinite(override)) return override > 0 ? override : null; + } + if (!settings.order_minimum_enabled) return null; + const amount = settings.order_minimum_amount; + return amount !== null && amount > 0 ? amount : null; +} + +/** How far under the minimum a total is, in dollars, or null when it clears it. */ +export function minimumShortfall(total: number, minimum: number | null): number | null { + if (minimum === null) return null; + const shortfall = Math.round((minimum - total) * 100) / 100; + return shortfall > 0 ? shortfall : null; +} + +const money = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }); + +/** Why a submit is blocked. Every blocked path shows this. */ +export function minimumBlockedMessage( + brandName: string, + minimum: number, + shortfall: number +): string { + return `This order is ${money.format(shortfall)} under ${brandName}'s ${money.format(minimum)} minimum. Only ${brandName} can submit it.`; +} + +/** Added where the writer can still save a draft instead (the order wizard). */ +export function minimumDraftHint(brandName: string): string { + return `Save it as a draft for ${brandName} to review.`; +} + +/** Shown to a buyer at checkout, who has no draft to fall back on. */ +export function minimumBuyerMessage(brandName: string, minimum: number, shortfall: number): string { + return `Add ${money.format(shortfall)} to reach ${brandName}'s ${money.format(minimum)} minimum.`; +} + +/** Shown to the brand itself, which may submit anyway. */ +export function minimumOverrideNote(minimum: number, shortfall: number): string { + return `This order is ${money.format(shortfall)} under your ${money.format(minimum)} minimum. You can still submit it.`; +} diff --git a/src/routes/api/orders/[id]/status/+server.ts b/src/routes/api/orders/[id]/status/+server.ts index d31d4e41..075a32df 100644 --- a/src/routes/api/orders/[id]/status/+server.ts +++ b/src/routes/api/orders/[id]/status/+server.ts @@ -8,6 +8,8 @@ import { mayAdvanceOrderStatus, FULFILLMENT_STATUS_ERROR } from '$lib/utils/order-status-permissions.js'; +import { supabaseAdmin } from '$lib/server/supabase.js'; +import { findMinimumViolations, blockingViolations } from '$lib/server/orders/order-minimum.js'; const ALLOWED_TRANSITIONS: Record = { draft: ['submitted'], @@ -48,7 +50,7 @@ export const PATCH: RequestHandler = async ({ params, request, locals, url }) => const { data: order, error: fetchErr } = await supabase .from('orders') - .select('id, order_number, status, total_amount, brand_id, account_id, created_by') + .select('id, order_number, order_type, status, total_amount, brand_id, account_id, created_by') .eq('id', params.id) .single(); @@ -88,6 +90,38 @@ export const PATCH: RequestHandler = async ({ params, request, locals, url }) => ); } + // Brand minimum (SCO-137): only the brand's own org may submit an order + // under its minimum. The order was already read through RLS above, so the + // service role here only looks up the brand's settings and the account + // override, which a connected rep cannot read directly. + if (newStatus === 'submitted' && order.order_type !== 'note') { + const blocks = blockingViolations( + await findMinimumViolations( + supabaseAdmin, + [ + { + brand_id: order.brand_id, + account_id: order.account_id, + total_amount: order.total_amount + } + ], + // A buyer never overrides, even when its org is the brand's. + locals.isBuyer ? null : organization.id + ) + ); + if (blocks.length > 0) { + locals.audit.record('order.status_changed', { + subjectId: order.id, + subjectLabel: order.order_number, + status: 'failure', + errorCode: 'under_minimum', + errorMessage: blocks[0].message, + changes: { status: { before: order.status, after: newStatus } } + }); + return json({ error: blocks[0].message }, { status: 400 }); + } + } + const updateData: Record = { status: newStatus, updated_at: new Date().toISOString() diff --git a/src/routes/api/webhooks/inbound-email/+server.ts b/src/routes/api/webhooks/inbound-email/+server.ts index be94a41a..95ecce15 100644 --- a/src/routes/api/webhooks/inbound-email/+server.ts +++ b/src/routes/api/webhooks/inbound-email/+server.ts @@ -10,7 +10,11 @@ import type { ReceivedEmail } from '$lib/server/email-intake/brevo-inbound.js'; import { resolveOrgFromSender } from '$lib/server/email-intake/route.js'; import { parseInboundOrder, OrderExtractionInvalidError } from '$lib/server/email-intake/parser.js'; import { resolveEntities } from '$lib/server/email-intake/resolve.js'; -import { decideOutcome, executeOutcome } from '$lib/server/email-intake/outcome.js'; +import { + applyOrderMinimum, + decideOutcome, + executeOutcome +} from '$lib/server/email-intake/outcome.js'; import { evaluateSenderAuth } from '$lib/server/email-intake/authentication.js'; import { reportAiError } from '$lib/server/ai-errors.js'; @@ -162,7 +166,7 @@ async function processItem( .update({ organization_id: resolved.organizationId }) .eq('id', intakeId); - const outcome = decideOutcome(resolved, senderAuth); + const outcome = await applyOrderMinimum(resolved, decideOutcome(resolved, senderAuth)); await executeOutcome(intakeId, resolved, outcome); return { intake_id: intakeId, status: outcome.status }; diff --git a/src/routes/orders/[id]/+page.server.ts b/src/routes/orders/[id]/+page.server.ts index ccc45cff..7c1fef39 100644 --- a/src/routes/orders/[id]/+page.server.ts +++ b/src/routes/orders/[id]/+page.server.ts @@ -11,6 +11,7 @@ import { FEDERATED_CONVERT_DENIED_ERROR } from '$lib/utils/order-convert-permissions.js'; import { resolveReturnEntry } from '$lib/server/returns/entry-point.js'; +import { findMinimumViolations, blockingViolations } from '$lib/server/orders/order-minimum.js'; import type { SourceOrderForReturn } from '$lib/server/returns/create-return.js'; export const load: PageServerLoad = async ({ locals, params, depends }) => { @@ -349,6 +350,30 @@ export const load: PageServerLoad = async ({ locals, params, depends }) => { actorOrgId: organization?.id ?? null }); + // A draft under its brand's minimum (SCO-137): the brand sees that it may + // submit anyway, anyone else sees why Submit is off. Service role only for + // the settings lookup; the order itself was read through RLS above. + const draft = orderResult.data as unknown as { + status: string; + order_type: string; + brand_id: string | null; + account_id: string | null; + total_amount: number | string | null; + }; + let minimumNotice: { message: string; blocked: boolean } | null = null; + if (draft.status === 'draft' && draft.order_type === 'order' && draft.brand_id) { + const [violation] = await findMinimumViolations( + supabaseAdmin, + [ + { brand_id: draft.brand_id, account_id: draft.account_id, total_amount: draft.total_amount } + ], + locals.isBuyer ? null : (organization?.id ?? null) + ); + if (violation) { + minimumNotice = { message: violation.message, blocked: !violation.canOverride }; + } + } + return { order: orderResult.data, invoice: invoiceResult.data ?? null, @@ -360,6 +385,7 @@ export const load: PageServerLoad = async ({ locals, params, depends }) => { today: new Date().toISOString().slice(0, 10), lines: linesResult.data ?? [], returnEntry, + minimumNotice, productsById, brandAssets: brandAssetsRes.data ?? [], commissionOverride: overrideRes.data?.rate ?? null, @@ -472,7 +498,7 @@ export const actions: Actions = { const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; const baseOrder = supabaseAdmin .from('orders') - .select('id, organization_id, order_type, brand_id, account_id'); + .select('id, organization_id, order_type, brand_id, account_id, total_amount'); const orderRes = await (UUID_RE.test(params.id) ? baseOrder.eq('id', params.id).single() : baseOrder.eq('order_number', params.id).single()); @@ -483,6 +509,7 @@ export const actions: Actions = { order_type: string; brand_id: string; account_id: string | null; + total_amount: number | string | null; }; if (row.organization_id !== organization.id) { // Cross-org: allow when there's an active federated_order_links row @@ -524,6 +551,13 @@ export const actions: Actions = { }); } + // Converting submits the order, so the brand minimum applies (SCO-137). + // The brand's own org may convert under it; anyone else keeps the note. + const [minimumBlock] = blockingViolations( + await findMinimumViolations(supabaseAdmin, [row], locals.isBuyer ? null : organization.id) + ); + if (minimumBlock) return fail(400, { message: minimumBlock.message }); + const now = new Date().toISOString(); const update: Record = { order_type: 'order', diff --git a/src/routes/orders/[id]/+page.svelte b/src/routes/orders/[id]/+page.svelte index 940b5f80..a4a0ad01 100644 --- a/src/routes/orders/[id]/+page.svelte +++ b/src/routes/orders/[id]/+page.svelte @@ -1564,6 +1564,7 @@ Shipping is at buyer's expense unless otherwise agreed in writing. Shipping fees {#each advanceableStatuses as nextStatus (nextStatus)}