Compliance - Services Documentation

Location: api/nextango/apps/compliance/services/, api/nextango/apps/compliance/cannabis/services/ Last Updated: 2026-07-06

Overview

The agnostic app hosts the genuinely vertical-neutral services in compliance/services/: the RegulatedCustomerOnboardingService orchestrator, the TaxClassificationService rate-filter engine, the ComplianceReportService, and the vertical-provider seam. The cannabis container in compliance/cannabis/services/ holds the vertical-specific work: age verification, cannabis tax classification and the vape excise, employee credential gating, purchase limits, recall handling, and the CannabisComplianceProvider.

Two agnostic-path modules, compliance/services/age_verification.py and compliance/services/employee_compliance_service.py, are legacy-import compatibility shims: they contain no logic and only re-export the canonical classes from the cannabis container so pre-containment import paths keep resolving. The shims are backward compatibility, not the architecture; the mechanism by which agnostic code reaches vertical behavior is the lazy get_active_vertical_provider() seam documented below. All services are stateless; instantiate per request.

AgeVerificationService

Location: compliance/cannabis/services/age_verification.py (canonical home). The agnostic path compliance/services/age_verification.py is a legacy-import compatibility shim that holds no logic.

Records age and identity verification events at the POS and enforces age gates at checkout. Session verification is valid for a configurable window (default 24 hours, controlled by settings.AGE_VERIFICATION_SESSION_HOURS).

Methods

is_verified_for_session(customer, store)

Returns True if the customer has an APPROVED verification event at the given store within the current session window. Does not raise; callers decide whether to gate.

record_verification(customer, store, employee, verification_type, result, id_expiry_date=None, dob=None, transaction=None, notes='')

Creates a CannabisAgeVerificationEvent atomically. If result is APPROVED and dob is provided, a government_id_verification credential is written to the customer asynchronously after the event commits. The audit event is intact even if the credential write fails; the customer re-presents ID on their next visit.

Returns the created event instance.

validate_medical_card(customer)

Checks whether the customer has an active medical_marijuana_patient_card credential.

Returns: {'valid': bool, 'status': str, 'reason': str}

get_customer_menu_type(customer)

Returns 'medical' if the customer has an active, unexpired medical card credential, 'adult_use' for all others.

check_minimum_age(dob, transaction_type='adult_use')

Checks whether a date of birth meets the minimum age for the transaction type. Minimum age is 21 for adult-use, 18 for medical.

Returns: {'passes': bool, 'age': int, 'minimum_required': int}

enforce_age_gate(customer, store)

Hard gate called immediately before checkout completion. Reads the age-gate flag from the store's compliance profile (store.compliance_profile.require_age_gate); a store with no compliance profile is treated as gate-off. If the flag is True and the customer is not verified for the current session, raises AgeGateError.

Exceptions

ExceptionWhen raised
AgeVerificationErrorHard check failure (e.g. minimum age not met)
AgeGateErrorCheckout attempted without passing age verification

TaxClassificationService

Location: compliance/services/tax_classification.py

The agnostic rate-filter engine. It filters a jurisdiction's rate components by compliance tier and names no industry vocabulary. Cannabis-specific tier classification and the vape excise live in CannabisTaxService, documented below.

The store has a single tax_jurisdiction FK, the geographic container for rate components. Rate filtering by tier happens at the rate-component level (each component's appliesTo field), not at the jurisdiction level. The tier for a transaction comes from transaction.compliance_profile.transaction_type; a transaction with no compliance profile receives the active vertical's default tier through the transaction-compliance seam.

Methods

get_jurisdiction_for_transaction(transaction)

Returns the TaxJurisdiction for the transaction's store, or None if the store has no jurisdiction configured.

get_applicable_rates(transaction)

Filters jurisdiction.tax_rates_data to the components applicable for this transaction's tier.

Inclusion rules:

  • appliesTo == 'all', always included
  • appliesTo == <tier>, included when the transaction is that tier
  • appliesTo missing or null, treated as 'all' (backward compatible)
  • isActive == False, always excluded

Returns a list of rate dicts from tax_rates_data. Returns an empty list when the store has no jurisdiction.

calculate_base_rate(transaction)

Sums the applicable rate components as a decimal (percentage ÷ 100). Does not include any vertical excise. Returns Decimal rounded to 4 decimal places.

build_tax_breakdown(transaction)

Returns the applicable base rate components for the transaction's tier, the output of get_applicable_rates(). A vertical's additional excise is appended at the model layer through the active vertical's additional_excise slot, not here.


CannabisTaxService

Location: compliance/cannabis/services/tax_classification.py

The cannabis-vertical tax logic split out of the agnostic service: customer-type classification, the profile write it drives, and the Ohio vape excise.

Methods

classify_transaction_type(customer, sale_transaction=None)

Returns 'medical' if the customer has an active medical card credential, 'adult_use' for all others including None. This is the only place transaction_type is derived, never from a client payload. When sale_transaction is provided and it contains cannabis line items, the method also ensures a SaleTransactionCannabisProfile exists with transaction_type set to the returned value. Non-cannabis transactions skip profile creation.

has_vape_items(transaction)

Returns True if any line item's variant has a ProductVariantCannabisProfile with vape_tax_applicable=True. Transactions with no such variants return False.

additional_excise(transaction, taxable_line_items, order_level_discount, base_subtotal)

Returns the vape excise component for the transaction over its taxable vape line items, each carrying its computed dollar amount. The order-level discount is split to the vape subtotal proportionally by base_subtotal. The excise rate applies only when the store's jurisdiction is not exempt; an exempt jurisdiction still emits the component with amount 0.00. Returns an empty list when there are no vape items. Rate controlled by settings.OHIO_VAPE_TAX_RATE (default 0.10). This is the slot the model layer calls through the vertical provider.

Component format:

{
  "name": "Vape Excise (Ohio)",
  "type": "vape_excise",
  "rate": 10.0,
  "appliesTo": "adult_use",
  "isActive": true,
  "vapeOnly": true,
  "amount": "1.50"
}

Vertical Provider Seam

Location: compliance/services/vertical_provider.py, compliance/cannabis/services/compliance_provider.py

Agnostic callers reach the active vertical's compliance behavior through get_active_vertical_provider(), which returns the vertical's provider without the caller importing a vertical module. Cannabis is the vertical shipping today, so the resolver returns CannabisComplianceProvider.

The seam defines three agnostic exceptions raised by the provider: StorePolicyNotFound (store has no compliance policy for the active vertical), EmployeeNotAuthorized (acting employee may not process credential review), and CheckoutGateBlocked (a pre-completion gate the transaction service maps to TransactionServiceError).

CannabisComplianceProvider

Plays two roles. As supplier and validator for RegulatedCustomerOnboardingService, and as command-side executor for the transaction service and the totals model.

get_store_policy(store) returns (required_credential_types, secondary_review_credential_types) from the store's StoreComplianceProfile. Raises StorePolicyNotFound when the profile is missing.

get_credential_validators() returns the credential-type to payload-validator mapping for the vertical.

check_employee_can_process(employee) raises EmployeeNotAuthorized if the employee may not process credential review.

upsert_store_profile(store, defaults) creates or updates the vertical's store profile with regulated-base field defaults (require_age_gate, required_credential_types, secondary_review_credential_types, permits). Owns the concrete-model write so agnostic store inbound sync never imports a vertical model.

run_pre_completion_gates(sale_transaction) runs the cannabis checkout gates in order before payment or status write. Gate 0 (employee credential) and Gate 2 (age verification) raise CheckoutGateBlocked. Gate 1 derives the transaction type. Gate 3 downgrades a medical tier to adult-use when the medical card is invalid. Gate 4 (menu-type) and Gate 5 (per-category purchase limit) raise their own native exceptions, which propagate unchanged.

record_purchase_completion(sale_transaction) records cannabis purchase-limit consumption after the transaction status is saved.

additional_excise(transaction, taxable_line_items, order_level_discount, base_subtotal) returns the vertical's additional excise components for the tax totals; delegates to CannabisTaxService.additional_excise.


PurchaseLimitEngine

Location: compliance/cannabis/services/purchase_limit_engine.py

Checks and records cannabis purchase limits at checkout. Enforces per-category daily limits for adult-use customers and a proxy monthly THC limit for medical customers.

Adult-use daily limits (calendar-day, store-local timezone):

CategoryLimitUnit
Flower2.5 ozoz/day
Concentrate15 gg/day
Edible15,000 mg THCmg THC/day

Medical: Single THC mg monthly proxy limit (90,000 mg/month) until state registry integration. Per-physician recommendation enforcement is a future extension.

Calendar-day vs. rolling: Limits reset at store-local midnight (uses store.timezone, default America/New_York). UTC midnight is not used; it would reset limits at 7–8 PM local time for Eastern stores.

Cross-dispensary limitation: consumption is scoped to a single store. A customer can purchase the daily maximum at Store A and purchase again at Store B. This gap closes when Metrc reporting enables state-level aggregation.

Methods

check_limit(customer, store, cart_items, transaction_type='adult_use')

Checks whether cart_items would exceed any per-category limit. Does not raise, returns a result dict. Use check_limit_or_raise() for a blocking gate.

cart_items format (one dict per cannabis line item):

{
    'limit_category': str,    # 'flower', 'concentrate', 'edible', 'other'
    'quantity_oz': Decimal,   # flower items
    'quantity_g': Decimal,    # concentrate items
    'thc_mg': Decimal,        # edible items
}

Returns:

{
    'allowed': bool,
    'period_type': str,
    'categories': {
        'flower':      {'allowed': bool, 'current_oz': Decimal, 'limit_oz': Decimal,
                        'remaining_oz': Decimal, 'cart_oz': Decimal},
        'concentrate': {'allowed': bool, 'current_g': Decimal, 'limit_g': Decimal,
                        'remaining_g': Decimal, 'cart_g': Decimal},
        'edible':      {'allowed': bool, 'current_mg': Decimal, 'limit_mg': Decimal,
                        'remaining_mg': Decimal, 'cart_mg': Decimal},
    }
}

check_limit_or_raise(customer, store, cart_items, transaction_type='adult_use')

Like check_limit(), but raises PurchaseLimitExceededError if any category limit is exceeded.

check_menu_type_restrictions(transaction)

Raises MenuTypeViolationError if an adult-use customer has a medical-only variant in the transaction. Call before saving the transaction.

calculate_cart_thc_mg(transaction)

Sums thc_mg_snapshot × quantity across all cannabis line items. Excludes line items where thc_mg_snapshot is None. Returns Decimal.

record_purchase(transaction)

Creates per-category CannabisPurchaseLimitRecord rows for a completed transaction. Called by the checkout service after SaleTransaction is saved and line items have thc_mg_snapshot populated. Atomic. Returns a list of created records (empty list if no cannabis items).

Limit category → unit mapping:

Categorycategory_amount unit
floweroz (from unit_weight_mg → oz)
concentrateg (from unit_weight_mg → g)
ediblemg THC (thc_mg_snapshot × quantity)
other0 (no limit tracking)

void_purchase(transaction, return_obj)

Marks CannabisPurchaseLimitRecord rows for the transaction as voided (is_voided=True). Called when a return is processed to restore the customer's limit headroom. Atomic.

Exceptions

ExceptionWhen raised
PurchaseLimitExceededErrorCart would exceed a per-category daily limit
MenuTypeViolationErrorAdult-use customer has a medical-only variant

EmployeeComplianceService

Location: compliance/cannabis/services/employee_compliance_service.py (canonical home). The agnostic path compliance/services/employee_compliance_service.py is a legacy-import compatibility shim that holds no logic.

Gate 0 of the cannabis checkout stack: the employee on the transaction must have an active, unexpired DCC agent permit and a cleared background check before any sale is finalised. Credentials are read from user.credentials (JSONField), no flat columns on Employee.

Methods

check_can_process_sale(user)

Raises EmployeeCredentialError when any of:

  • user is None (fail-closed, cannabis sales require an authenticated employee on the transaction)
  • No active, unexpired dcc_agent_permit credential is present
  • No active background_check credential is present, or its payload.outcome is not 'cleared'

Called immediately before checkout completion for cannabis transactions, and reused by RegulatedCustomerOnboardingService to gate approve/reject actions.

Exceptions

ExceptionWhen raised
EmployeeCredentialErrorEmployee lacks valid credentials to process cannabis sales

RegulatedCustomerOnboardingService

Location: compliance/services/regulated_customer_onboarding_service.py

Vertical-agnostic onboarding orchestration. Both POS and storefront channels route through a single service entrypoint and receive channel-appropriate initial credential states.

Pending statuses: pending_verification, pending_employee_review, pending_vendor_response.

Stored verificationData fields: verifiedDocumentId, verifiedBiometricId, reviewNotes. Any key outside this set is dropped before persisting, the stored object matches the Sanity credential object schema so credentials sync bidirectionally.

Methods

create_with_credentials(user_data, credentials_list, channel)

Atomic user + credentials creation with store-policy enforcement. The store credential policy and the credential-payload validators are supplied by the active vertical provider, so this orchestrator names no vertical concept.

  • channel must be 'pos' or 'storefront', otherwise OnboardingError.
  • Resolves user_data.store_id to a Store, a missing store raises OnboardingError.
  • Obtains the store's required and secondary-review credential types from the provider (get_store_policy), a store with no compliance policy raises OnboardingError.
  • Every required credential type must be present in credentials_list.
  • Each submitted credential's credentialType is dispatched through the provider's validators (get_credential_validators), unknown types and invalid payloads both raise OnboardingError.
  • A roleCustomer entry is appended to user_roles_data if absent, so the User post-save signal creates the joined users.Customer row.

Initial status per channel:

ChannelCredential type in secondary_review_credential_typesOther credential types
pospending_employee_reviewactive
storefrontpending_verificationpending_verification

Returns the created User.

approve_pending_credential(credential_key, employee, verification_data)

Employee authorization is checked through the active vertical provider (check_employee_can_process); an unauthorized employee raises OnboardingError. The credential must be in one of the pending statuses. Sets status='active', stamps approvedBy/approvedAt, and replaces verificationData with the filtered subset of keys in VERIFICATION_DATA_FIELDS. Returns the updated credential dict.

reject_pending_credential(credential_key, employee, reason)

Same employee and state preconditions. Sets status='rejected', stamps rejectedBy/rejectedAt, records rejectionReason. Returns the updated credential dict.

Exceptions

ExceptionWhen raised
OnboardingErrorInvalid channel, missing store/profile, missing required credential type, unknown credential type, invalid payload, employee not authorised, credential not found, credential not in a pending status

ComplianceReportService

Location: compliance/services/report_service.py

Creates ComplianceReport records and enqueues the generate_compliance_report Celery task. Generators receive a resolved store queryset, never Store.objects.all().

Methods

request_report(report_type, store, date_from, date_to, requested_by)

Calls _assert_store_access then creates a ComplianceReport with status='pending' and enqueues the generation task via .delay(). Returns the created ComplianceReport.

Access rules enforced by _assert_store_access:

  • store=None is accepted, generators resolve the permitted stores from StoreComplianceProfile.compliance_manager.
  • requested_by=None is accepted only when store=None (admin tooling path).
  • Otherwise requested_by must be the compliance_manager on the target store's StoreComplianceProfile, else PermissionDenied.

get_report_status(report_id)

Returns the ComplianceReport for pk=report_id. Raises DoesNotExist if missing.


Report Generators (Cannabis Vertical)

Location: compliance/cannabis/reports/

Each generator exposes generate(report: ComplianceReport), writes the output CSV to report.output_file, sets report.generated_at = timezone.now() and report.status = READY. Generators resolve their store scope via _get_stores(report), report.store=None expands to the set of stores where requested_by is compliance_manager.

GeneratorRow grain
DailySalesReportGeneratorOne row per (store, sale date) with gross/net/tax totals and adult-use vs. medical splits
PurchaseLimitAuditReportGeneratorOne row per CannabisPurchaseLimitRecord
AgeVerificationAuditReportGeneratorOne row per CannabisAgeVerificationEvent
RecallDestructionAuditReportGeneratorOne row per CannabisDestructionEvent, joined with any linked recall
CashReconciliationReportGeneratorOne row per CashierShift

CashReconciliationReportGenerator

One row per CashierShift in scope. Payment totals aggregate TransactionPayment rows scoped to the shift's store and started_at → ended_at window. Blind-count totals come from the close-of-shift DrawerCount.

IRS 8300 flag: advisory column. Cumulative cash receipts per store per day ≥ $10,000 sets irs_8300_flag to True. Threshold is IRS_8300_THRESHOLD = Decimal('10000.00').

Columns (CSV, in order):

shift_date, shift_id, store_id, terminal_id, employee_id,
opening_float, closing_float_system, blind_count_total, count_discrepancy,
cash_sales_total, card_sales_total, cashless_atm_sales_total, other_sales_total,
total_expected, irs_8300_flag, irs_8300_cumulative_total

cashless_atm_sales_total is always 0.00 until cashless_atm is added as a TransactionPayment.payment_method_type choice; the column is pre-wired for that future phase.


CannabisRecallService

Location: compliance/cannabis/services/recall_service.py

Cannabis-specific recall lifecycle: activating a recall, recording destructions against affected inventory, and resolving the recall once all destructions are reported. Writes CannabisDestructionEvent rows (immutable) and flips InventoryLevelCannabisProfile.is_quarantined on recall activation.

Was this page helpful?