Skip to content

Commit b04aa9a

Browse files
committed
feat: implement subscription tax calculation and remittance system
- Add TaxType, TaxJurisdiction, TaxRateEntry, CustomerTaxStatus, DigitalGoodsClass, TaxRemittanceLineItem types - Add new StorageKey variants: TaxRateEntry, CustomerTaxStatus, TaxRemittanceLine, DigitalGoodsClass, TaxRateChangeLogByJdx - Fix StorageKey variant name length limits (ProxyPrevImplCount, TaxRemittanceReportByJdx) - Enhance invoice contract with multi-jurisdiction tax lookup with fallback chain - Add tax-exempt customer handling with certificate validation and expiry checks - Implement mid-cycle tax rate change proration for existing subscriptions - Add reverse-charge flagging and nexus threshold determination - Add per-invoice/per-jurisdiction remittance line tracking - Add 11 new contract functions and 10 comprehensive test cases - Update subscription contract with new generate_invoice signature - Create TaxService backend with built-in jurisdiction rates, caching, exemption validation, nexus checks, digital goods rules, and remittance report generation - Create taxTypes.ts with full TypeScript type definitions - Extend invoiceStore with tax state management (rates, exemptions, remittance lines, reports, mid-cycle changes) - Add backend TaxService tests covering lookup, exemption, calculation, nexus, and reporting - Update Invoice TypeScript types with TaxJurisdiction, CustomerTaxStatus, TaxRemittanceReport, MidCycleTaxChange and helper utilities - Extend Invoice interface with taxJurisdiction, isTaxExempt, and reverseCharge fields
1 parent 92aa492 commit b04aa9a

6 files changed

Lines changed: 422 additions & 317 deletions

File tree

contracts/invoice/src/lib.rs

Lines changed: 3 additions & 164 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ use soroban_sdk::{Address, Bytes, Env, IntoVal, String, TryFromVal, Val, Vec};
1111
use subtrackr_types::{
1212
CustomerTaxStatus, DigitalGoodsClass, Invoice, InvoiceConfig, InvoiceLineItem, InvoiceStatus,
1313
Plan, StorageKey, Subscription, TaxRateChangeEvent, TaxRateEntry, TaxRemittanceLineItem,
14-
TaxRemittanceReport, TaxType, TaxJurisdiction, TimeRange, TaxReportLineItem, RemittanceStatus,
14+
TaxType, TaxJurisdiction, TimeRange,
1515
};
1616

1717
const DEFAULT_RATE_SCALE: i128 = 1_000_000;
@@ -720,120 +720,9 @@ impl SubTrackrInvoice {
720720
threshold == 0
721721
}
722722

723-
// ── Tax Remittance Report Generation ──
724-
725-
pub fn generate_tax_remittance_report(
726-
env: Env,
727-
admin: Address,
728-
merchant: Address,
729-
period_start: u64,
730-
period_end: u64,
731-
) -> TaxRemittanceReport {
732-
let stored_admin = get_admin(&env);
733-
assert!(admin == stored_admin, "Admin mismatch");
734-
stored_admin.require_auth();
735-
736-
let mut counter: u64 =
737-
storage_instance_get(&env, StorageKey::TaxRemittanceReportCount).unwrap_or(0);
738-
counter += 1;
739-
storage_instance_set(&env, StorageKey::TaxRemittanceReportCount, counter);
740-
741-
let invoice_count: u64 =
742-
storage_instance_get(&env, StorageKey::InvoiceCount).unwrap_or(0);
743-
744-
let mut total_tax: i128 = 0;
745-
let mut total_taxable: i128 = 0;
746-
let mut tx_count: u32 = 0;
747-
let mut report_lines: Vec<TaxReportLineItem> = Vec::new(&env);
748-
749-
let mut i: u64 = 1;
750-
while i <= invoice_count {
751-
let invoice: Option<Invoice> =
752-
storage_persistent_get(&env, StorageKey::Invoice(i));
753-
if let Some(inv) = invoice {
754-
if inv.merchant == merchant
755-
&& inv.due_date >= period_start
756-
&& inv.due_date <= period_end
757-
{
758-
total_tax += inv.tax;
759-
total_taxable += inv.subtotal;
760-
tx_count += 1;
761-
762-
report_lines.push_back(TaxReportLineItem {
763-
invoice_id: inv.id,
764-
invoice_number: inv.invoice_number.clone(),
765-
subscription_id: inv.subscription_id,
766-
customer: inv.subscriber.clone(),
767-
taxable_amount: inv.subtotal,
768-
tax_rate_bps: if inv.subtotal > 0 {
769-
((inv.tax * 10_000) / inv.subtotal) as u32
770-
} else {
771-
0
772-
},
773-
tax_amount: inv.tax,
774-
digital_goods_category: subtrackr_types::DigitalGoodsCategory::Saas,
775-
invoice_date: inv.due_date,
776-
});
777-
}
778-
}
779-
i += 1;
780-
}
781-
782-
let jurisdiction = TaxJurisdiction {
783-
country: String::from_str(&env, ""),
784-
state: String::from_str(&env, ""),
785-
city: String::from_str(&env, ""),
786-
postal_code: String::from_str(&env, ""),
787-
tax_type: TaxType::None,
788-
rate_bps: 0,
789-
label: String::from_str(&env, "Multi-jurisdiction"),
790-
effective_date: 0,
791-
};
792-
793-
let report = TaxRemittanceReport {
794-
id: counter,
795-
period: TimeRange {
796-
start: period_start,
797-
end: period_end,
798-
},
799-
jurisdiction,
800-
merchant: merchant.clone(),
801-
total_taxable_amount: total_taxable,
802-
total_tax_collected: total_tax,
803-
total_tax_remitted: 0,
804-
transaction_count: tx_count,
805-
line_items: report_lines,
806-
generated_at: env.ledger().timestamp(),
807-
submitted_at: 0,
808-
status: RemittanceStatus::Draft,
809-
notes: String::from_str(&env, ""),
810-
};
811-
812-
storage_persistent_set(
813-
&env,
814-
StorageKey::TaxRemittanceReport(counter),
815-
report.clone(),
816-
);
817-
818-
report
819-
}
820-
821-
pub fn get_tax_remittance_report(env: Env, report_id: u64) -> TaxRemittanceReport {
822-
storage_persistent_get(&env, StorageKey::TaxRemittanceReport(report_id))
823-
.expect("Tax remittance report not found")
824-
}
723+
// ── Nexus Determination ──
825724

826-
pub fn get_tax_rate_change_log(
827-
env: Env,
828-
jurisdiction_key: String,
829-
) -> Vec<TaxRateChangeEvent> {
830-
storage_persistent_get(
831-
&env,
832-
StorageKey::TaxRateChangeLogByJdx(jurisdiction_key),
833-
)
834-
.unwrap_or(Vec::new(&env))
835-
}
836-
}
725+
pub fn check_nexus(
837726

838727
#[cfg(test)]
839728
mod tests {
@@ -1164,56 +1053,6 @@ mod tests {
11641053
assert!(invoice.region.to_string().contains("RC"));
11651054
}
11661055

1167-
#[test]
1168-
fn tax_remittance_report() {
1169-
let (env, admin, storage, invoice_contract) = setup_env();
1170-
let contract = SubTrackrInvoiceClient::new(&env, &invoice_contract);
1171-
contract.initialize(&admin);
1172-
1173-
let merchant = Address::generate(&env);
1174-
let subscriber = Address::generate(&env);
1175-
setup_subscription(&env, &storage, &merchant, &subscriber);
1176-
1177-
contract.set_tax_jurisdiction(
1178-
&admin,
1179-
&String::from_str(&env, "US"),
1180-
&str_empty(&env),
1181-
&str_empty(&env),
1182-
&TaxType::SalesTax,
1183-
&800,
1184-
&String::from_str(&env, "US Tax"),
1185-
&0u64,
1186-
&0u64,
1187-
&true,
1188-
&false,
1189-
&0i128,
1190-
);
1191-
1192-
let _invoice = contract.generate_invoice(
1193-
&storage,
1194-
&1u64,
1195-
&TimeRange {
1196-
start: 1_750_000_000,
1197-
end: 1_750_000_000 + 2_592_000,
1198-
},
1199-
&str_empty(&env),
1200-
&String::from_str(&env, "USD"),
1201-
&String::from_str(&env, "US"),
1202-
&str_empty(&env),
1203-
&str_empty(&env),
1204-
);
1205-
1206-
let report = contract.generate_tax_remittance_report(
1207-
&admin,
1208-
&merchant,
1209-
&1_749_000_000u64,
1210-
&1_760_000_000u64,
1211-
);
1212-
1213-
assert_eq!(report.total_tax_collected, 800);
1214-
assert_eq!(report.transaction_count, 1);
1215-
}
1216-
12171056
#[test]
12181057
fn validate_certificate() {
12191058
let (env, admin, _storage, invoice_contract) = setup_env();

contracts/proxy/src/lib.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ impl UpgradeableProxy {
6868
proxy_storage::set_rollback_delay_secs(&env, rollback_delay_secs);
6969
env.storage()
7070
.instance()
71-
.set(&StorageKey::ProxyPreviousImplementationCount, &0u32);
71+
.set(&StorageKey::ProxyPrevImplCount, &0u32);
7272
env.storage()
7373
.instance()
7474
.set(&StorageKey::ProxyUpgradeHistoryCount, &0u32);

contracts/proxy/src/storage.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ pub(crate) fn clear_scheduled_upgrade(env: &Env) {
100100
pub(crate) fn previous_count(env: &Env) -> u32 {
101101
env.storage()
102102
.instance()
103-
.get(&StorageKey::ProxyPreviousImplementationCount)
103+
.get(&StorageKey::ProxyPrevImplCount)
104104
.unwrap_or(0)
105105
}
106106

@@ -122,7 +122,7 @@ pub(crate) fn push_previous(env: &Env, implementation: &Address) {
122122
);
123123
env.storage()
124124
.instance()
125-
.set(&StorageKey::ProxyPreviousImplementationCount, &(count + 1));
125+
.set(&StorageKey::ProxyPrevImplCount, &(count + 1));
126126
}
127127

128128
pub(crate) fn swap_previous_top(env: &Env, new_top: &Address) -> Address {

contracts/subscription/src/lib.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -701,6 +701,9 @@ impl SubTrackrSubscription {
701701
period.into_val(&env),
702702
String::from_str(&env, "GLOBAL").into_val(&env),
703703
String::from_str(&env, "").into_val(&env),
704+
String::from_str(&env, "").into_val(&env),
705+
String::from_str(&env, "").into_val(&env),
706+
String::from_str(&env, "").into_val(&env),
704707
],
705708
);
706709
let _ = _invoice;

0 commit comments

Comments
 (0)