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
| Exception | When raised |
|---|---|
AgeVerificationError | Hard check failure (e.g. minimum age not met) |
AgeGateError | Checkout 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 includedappliesTo == <tier>, included when the transaction is that tierappliesTomissing 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):
| Category | Limit | Unit |
|---|---|---|
| Flower | 2.5 oz | oz/day |
| Concentrate | 15 g | g/day |
| Edible | 15,000 mg THC | mg 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:
| Category | category_amount unit |
|---|---|
flower | oz (from unit_weight_mg → oz) |
concentrate | g (from unit_weight_mg → g) |
edible | mg THC (thc_mg_snapshot × quantity) |
other | 0 (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
| Exception | When raised |
|---|---|
PurchaseLimitExceededError | Cart would exceed a per-category daily limit |
MenuTypeViolationError | Adult-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:
userisNone(fail-closed, cannabis sales require an authenticated employee on the transaction)- No active, unexpired
dcc_agent_permitcredential is present - No active
background_checkcredential is present, or itspayload.outcomeis not'cleared'
Called immediately before checkout completion for cannabis transactions, and reused by RegulatedCustomerOnboardingService to gate approve/reject actions.
Exceptions
| Exception | When raised |
|---|---|
EmployeeCredentialError | Employee 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.
channelmust be'pos'or'storefront', otherwiseOnboardingError.- Resolves
user_data.store_idto aStore, a missing store raisesOnboardingError. - Obtains the store's required and secondary-review credential types from the provider (
get_store_policy), a store with no compliance policy raisesOnboardingError. - Every required credential type must be present in
credentials_list. - Each submitted credential's
credentialTypeis dispatched through the provider's validators (get_credential_validators), unknown types and invalid payloads both raiseOnboardingError. - A
roleCustomerentry is appended touser_roles_dataif absent, so the User post-save signal creates the joinedusers.Customerrow.
Initial status per channel:
| Channel | Credential type in secondary_review_credential_types | Other credential types |
|---|---|---|
pos | pending_employee_review | active |
storefront | pending_verification | pending_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
| Exception | When raised |
|---|---|
OnboardingError | Invalid 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=Noneis accepted, generators resolve the permitted stores fromStoreComplianceProfile.compliance_manager.requested_by=Noneis accepted only whenstore=None(admin tooling path).- Otherwise
requested_bymust be thecompliance_manageron the target store'sStoreComplianceProfile, elsePermissionDenied.
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.
| Generator | Row grain |
|---|---|
DailySalesReportGenerator | One row per (store, sale date) with gross/net/tax totals and adult-use vs. medical splits |
PurchaseLimitAuditReportGenerator | One row per CannabisPurchaseLimitRecord |
AgeVerificationAuditReportGenerator | One row per CannabisAgeVerificationEvent |
RecallDestructionAuditReportGenerator | One row per CannabisDestructionEvent, joined with any linked recall |
CashReconciliationReportGenerator | One 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.