hecks 2.9.0 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/exe/hecks +51 -6
- data/lib/hecks/adapters/driven/heki/journal.rb +1 -1
- data/lib/hecks/adapters/driven/lambda/client.rb +1 -1
- data/lib/hecks/adapters/driven/lambda.rb +1 -1
- data/lib/hecks/adapters/driven/memory.rb +16 -3
- data/lib/hecks/adapters/driven/postgres/reconnect.rb +6 -2
- data/lib/hecks/adapters/driven/postgres/shared_connection.rb +180 -0
- data/lib/hecks/adapters/driven/postgres.rb +4 -2
- data/lib/hecks/adapters/driven/prism.rb +41 -8
- data/lib/hecks/adapters/driven/tenant_provisioner.rb +73 -16
- data/lib/hecks/behaviors/expectations.rb +1 -1
- data/lib/hecks/behaviors.rb +1 -1
- data/lib/hecks/bench/cli.rb +62 -0
- data/lib/hecks/bench/environment.rb +71 -0
- data/lib/hecks/bench/postgres_probe.rb +30 -0
- data/lib/hecks/bench/report.rb +76 -0
- data/lib/hecks/bench/ruby_runner.rb +93 -0
- data/lib/hecks/bench/run.rb +55 -0
- data/lib/hecks/bench/rust_runner.rb +101 -0
- data/lib/hecks/bench/stats.rb +46 -0
- data/lib/hecks/bench/suite.rb +98 -0
- data/lib/hecks/bench/workload.rb +89 -0
- data/lib/hecks/bench.rb +17 -0
- data/lib/hecks/bluebook/assembly/contracts.rb +2 -0
- data/lib/hecks/bluebook/attribute.rb +1 -1
- data/lib/hecks/bluebook/behaviour/aggregate.rb +30 -1
- data/lib/hecks/bluebook/behaviour/domain_port.rb +7 -0
- data/lib/hecks/bluebook/chapter.rb +6 -2
- data/lib/hecks/bluebook/domain_port.rb +36 -5
- data/lib/hecks/bluebook/dsl/bluebook_builder/validation.rb +10 -0
- data/lib/hecks/bluebook/dsl/bluebook_builder.rb +8 -0
- data/lib/hecks/bluebook/dsl/bootstrap_table.rb +2 -1
- data/lib/hecks/bluebook/dsl/domain_port_builder.rb +31 -3
- data/lib/hecks/bluebook/dsl/hecksagon_builder.rb +122 -3
- data/lib/hecks/bluebook/dsl/lifecycle_builder.rb +1 -1
- data/lib/hecks/bluebook/dsl/query_builder.rb +27 -1
- data/lib/hecks/bluebook/dsl/rule_reference.rb +1 -1
- data/lib/hecks/bluebook/dsl/world_builder.rb +29 -2
- data/lib/hecks/bluebook/expression/canonical_form.rb +1 -1
- data/lib/hecks/bluebook/expression/resolver.rb +3 -2
- data/lib/hecks/bluebook/hexagon.rb +13 -5
- data/lib/hecks/bluebook/meta_validator/reconstruction.rb +1 -0
- data/lib/hecks/bluebook/meta_validator/syntax_boot.rb +4 -1
- data/lib/hecks/bluebook/meta_validator/verdict_cache.rb +246 -0
- data/lib/hecks/bluebook/meta_validator.rb +9 -4
- data/lib/hecks/bluebook/model_check.rb +23 -0
- data/lib/hecks/bluebook/pattern_subset.rb +38 -0
- data/lib/hecks/bluebook/query.rb +20 -5
- data/lib/hecks/bluebook/smoke_test.rb +1 -1
- data/lib/hecks/canonical_json.rb +30 -0
- data/lib/hecks/chapters.rb +102 -0
- data/lib/hecks/cli/behaviors.rb +61 -0
- data/lib/hecks/cli/canonicalise.rb +25 -0
- data/lib/hecks/cli/check_engine_agreement.rb +32 -0
- data/lib/hecks/cli/check_era.rb +88 -0
- data/lib/hecks/cli/console.rb +66 -0
- data/lib/hecks/cli/deploy_template_diff.rb +40 -0
- data/lib/hecks/cli/doc_coverage.rb +32 -0
- data/lib/hecks/cli/document.rb +2 -2
- data/lib/hecks/cli/expression_projection.rb +41 -0
- data/lib/hecks/cli/follow.rb +88 -0
- data/lib/hecks/cli/history.rb +44 -0
- data/lib/hecks/cli/ir.rb +1 -1
- data/lib/hecks/cli/mcp.rb +3 -3
- data/lib/hecks/cli/model_check.rb +7 -4
- data/lib/hecks/cli/pattern_cases.rb +47 -0
- data/lib/hecks/cli/present.rb +39 -0
- data/lib/hecks/cli/project_cli.rb +224 -32
- data/lib/hecks/cli/project_diagrams.rb +4 -3
- data/lib/hecks/cli/project_glossary.rb +44 -0
- data/lib/hecks/cli/reference.rb +46 -0
- data/lib/hecks/cli/refresh_projections.rb +44 -0
- data/lib/hecks/cli/refresh_rspec_runtime_baseline.rb +150 -0
- data/lib/hecks/cli/release.rb +70 -0
- data/lib/hecks/cli/release_gem.rb +59 -0
- data/lib/hecks/cli/rspec_io_parallel_files.rb +137 -0
- data/lib/hecks/cli/rspec_shard_files.rb +66 -0
- data/lib/hecks/cli/run.rb +3 -3
- data/lib/hecks/cli/seed_semantics_corpus.rb +71 -0
- data/lib/hecks/cli/serve_query_ir_mcp.rb +30 -0
- data/lib/hecks/cli/shape.rb +70 -0
- data/lib/hecks/cli/smoke_http.rb +230 -0
- data/lib/hecks/cli/smoke_test.rb +5 -3
- data/lib/hecks/cli/spec_example.rb +27 -0
- data/lib/hecks/cli/statements.rb +32 -0
- data/lib/hecks/cli/stores.rb +4 -5
- data/lib/hecks/cli/stress_concurrency_specs.rb +177 -0
- data/lib/hecks/cli.rb +201 -52
- data/lib/hecks/codemod.rb +324 -0
- data/lib/hecks/corpus.rb +423 -0
- data/lib/hecks/deploy/bluebook/deploy.bluebook +591 -23
- data/lib/hecks/deploy/bluebook/deploy.hecksagon +41 -4
- data/lib/hecks/deploy/oidc.json +82 -2
- data/lib/hecks/doc/reference.rb +351 -0
- data/lib/hecks/doors/cli_door.rb +196 -0
- data/lib/hecks/doors/cli_runner.rb +297 -0
- data/lib/hecks/{facade → doors}/command_request.rb +1 -1
- data/lib/hecks/{facade → doors}/handle.rb +2 -2
- data/lib/hecks/{facade → doors}/json_door.rb +4 -4
- data/lib/hecks/doors/launcher_options.rb +129 -0
- data/lib/hecks/{cli → doors}/mcp_door.rb +7 -7
- data/lib/hecks/doors/mcp_door_scope.rb +97 -0
- data/lib/hecks/{facade/surface → doors/ruby_door}/aggregate_door.rb +8 -3
- data/lib/hecks/{facade/surface → doors/ruby_door}/chapter.rb +3 -3
- data/lib/hecks/{facade/surface.rb → doors/ruby_door.rb} +36 -5
- data/lib/hecks/doors.rb +61 -0
- data/lib/hecks/embryonaut_bluebook/vendor_cli.rb +1 -1
- data/lib/hecks/embryonaut_bluebook.rb +1 -1
- data/lib/hecks/engine_agreement.rb +156 -0
- data/lib/hecks/facade.rb +24 -37
- data/lib/hecks/forms/app.rb +6 -6
- data/lib/hecks/forms/banking_presentation.rb +50 -0
- data/lib/hecks/forms/field_shape.rb +2 -1
- data/lib/hecks/forms/port_argument.rb +1 -1
- data/lib/hecks/fuzzing/bounded_exhaustive_expressions.rb +393 -0
- data/lib/hecks/fuzzing/combination_miner.rb +86 -0
- data/lib/hecks/fuzzing/concurrent_dispatch.rb +214 -0
- data/lib/hecks/fuzzing/coverage_campaign.rb +143 -0
- data/lib/hecks/fuzzing/differential.rb +156 -0
- data/lib/hecks/fuzzing/domain_generator.rb +615 -0
- data/lib/hecks/fuzzing/era_boundary.rb +71 -0
- data/lib/hecks/fuzzing/form_census.rb +136 -0
- data/lib/hecks/fuzzing/generated_domain_check.rb +93 -0
- data/lib/hecks/fuzzing/invalid_value_generator.rb +64 -0
- data/lib/hecks/fuzzing/isolated_boot.rb +301 -0
- data/lib/hecks/fuzzing/nondeterministic.rb +51 -0
- data/lib/hecks/fuzzing/outside_world.rb +45 -0
- data/lib/hecks/fuzzing/persistence_parity.rb +102 -0
- data/lib/hecks/fuzzing/properties/corrections.rb +88 -0
- data/lib/hecks/fuzzing/properties/dispatch_and_mutations.rb +314 -0
- data/lib/hecks/fuzzing/properties/guards.rb +190 -0
- data/lib/hecks/fuzzing/properties/invariants_and_aggregation.rb +300 -0
- data/lib/hecks/fuzzing/properties/lifecycle_and_replay.rb +93 -0
- data/lib/hecks/fuzzing/properties/outbox.rb +89 -0
- data/lib/hecks/fuzzing/properties/querying.rb +175 -0
- data/lib/hecks/fuzzing/properties.rb +162 -0
- data/lib/hecks/fuzzing/qa_settings.rb +123 -0
- data/lib/hecks/fuzzing/replay.rb +505 -0
- data/lib/hecks/fuzzing/rotation_priority.rb +48 -0
- data/lib/hecks/fuzzing/rust_gap_manifest.rb +108 -0
- data/lib/hecks/fuzzing/self_consistency.rb +339 -0
- data/lib/hecks/fuzzing/sequence_generator/adversary.rb +385 -0
- data/lib/hecks/fuzzing/sequence_generator/catalog.rb +132 -0
- data/lib/hecks/fuzzing/sequence_generator/outcome_tracker.rb +95 -0
- data/lib/hecks/fuzzing/sequence_generator/picker.rb +77 -0
- data/lib/hecks/fuzzing/sequence_generator/query_binding.rb +109 -0
- data/lib/hecks/fuzzing/sequence_generator/step_builder.rb +237 -0
- data/lib/hecks/fuzzing/sequence_generator.rb +189 -0
- data/lib/hecks/fuzzing/shrinker.rb +166 -0
- data/lib/hecks/fuzzing/structural_skips.rb +33 -0
- data/lib/hecks/fuzzing/sweep_depth.rb +43 -0
- data/lib/hecks/fuzzing/target_capabilities.rb +163 -0
- data/lib/hecks/fuzzing/value_generator.rb +216 -0
- data/lib/hecks/fuzzing.rb +23 -0
- data/lib/hecks/grammar/evolve.rb +336 -0
- data/lib/hecks/hecks/adapters/codebase/codemods.rb +45 -0
- data/lib/hecks/hecks/adapters/codebase/conformance.rb +71 -0
- data/lib/hecks/hecks/adapters/codebase/corpus_tasks.rb +202 -0
- data/lib/hecks/hecks/adapters/codebase/gem_registry.rb +80 -0
- data/lib/hecks/hecks/adapters/codebase/kernel_tables.rb +54 -0
- data/lib/hecks/hecks/adapters/codebase/language.rb +240 -0
- data/lib/hecks/hecks/adapters/codebase/npm_registry.rb +102 -0
- data/lib/hecks/hecks/adapters/codebase/publishing.rb +142 -0
- data/lib/hecks/hecks/adapters/codebase/regeneration.rb +67 -0
- data/lib/hecks/hecks/adapters/codebase/release_facts.rb +90 -0
- data/lib/hecks/hecks/adapters/codebase/ruby_child.rb +83 -0
- data/lib/hecks/hecks/adapters/codebase/secret_vault.rb +36 -0
- data/lib/hecks/hecks/adapters/codebase/source_tree.adapter +7 -0
- data/lib/hecks/hecks/adapters/codebase/source_tree.rb +189 -0
- data/lib/hecks/hecks/adapters/codebase/sqlite_fixture.rb +71 -0
- data/lib/hecks/hecks/adapters/codebase/style.rb +171 -0
- data/lib/hecks/hecks/adapters/codebase/test_runner.rb +55 -0
- data/lib/hecks/hecks/adapters/codebase/test_suite.rb +165 -0
- data/lib/hecks/hecks/adapters/codebase/tree.rb +111 -0
- data/lib/hecks/hecks/adapters/console_capture.rb +79 -0
- data/lib/hecks/hecks/adapters/deploy_toolchain.adapter +7 -0
- data/lib/hecks/hecks/adapters/deploy_toolchain.rb +108 -0
- data/lib/hecks/hecks/adapters/git.adapter +7 -0
- data/lib/hecks/hecks/adapters/git.rb +102 -0
- data/lib/hecks/hecks/adapters/host_http.adapter +7 -0
- data/lib/hecks/hecks/adapters/host_http.rb +65 -0
- data/lib/hecks/hecks/adapters/in_process_boot.adapter +7 -0
- data/lib/hecks/hecks/adapters/in_process_boot.rb +197 -0
- data/lib/hecks/hecks/adapters/in_process_operations.rb +202 -0
- data/lib/hecks/hecks/adapters/journal_store/changes.rb +143 -0
- data/lib/hecks/hecks/adapters/journal_store/compaction.rb +145 -0
- data/lib/hecks/hecks/adapters/journal_store/edge_audit.rb +132 -0
- data/lib/hecks/hecks/adapters/journal_store/examination.rb +135 -0
- data/lib/hecks/hecks/adapters/journal_store/held_domain.rb +163 -0
- data/lib/hecks/hecks/adapters/journal_store/readings.rb +187 -0
- data/lib/hecks/hecks/adapters/journal_store.adapter +7 -0
- data/lib/hecks/hecks/adapters/journal_store.rb +64 -0
- data/lib/hecks/hecks/adapters/local_files.adapter +7 -0
- data/lib/hecks/hecks/adapters/local_files.rb +46 -0
- data/lib/hecks/hecks/adapters/pg_admin.adapter +7 -0
- data/lib/hecks/hecks/adapters/pg_admin.rb +245 -0
- data/lib/hecks/hecks/adapters/process_pool.adapter +7 -0
- data/lib/hecks/hecks/adapters/process_pool.rb +119 -0
- data/lib/hecks/hecks/adapters/rust_toolchain.adapter +7 -0
- data/lib/hecks/hecks/adapters/rust_toolchain.rb +179 -0
- data/lib/hecks/hecks/adapters/rust_workspace.rb +150 -0
- data/lib/hecks/hecks/adapters/shell.adapter +7 -0
- data/lib/hecks/hecks/adapters/shell.rb +49 -0
- data/lib/hecks/hecks/adapters/terminal.adapter +7 -0
- data/lib/hecks/hecks/adapters/terminal.rb +74 -0
- data/lib/hecks/hecks/codebase.behaviors +172 -0
- data/lib/hecks/hecks/codebase.bluebook +2453 -0
- data/lib/hecks/hecks/context_map.hecksagon +13 -0
- data/lib/hecks/hecks/custodian.behaviors +179 -0
- data/lib/hecks/hecks/custodian.bluebook +1847 -0
- data/lib/hecks/hecks/deploy.behaviors +74 -0
- data/lib/hecks/hecks/deploy.world +12 -0
- data/lib/hecks/hecks/environments/memory.world +10 -0
- data/lib/hecks/hecks/governance.world +7 -0
- data/lib/hecks/hecks/hecks.behaviors +62 -0
- data/lib/hecks/hecks/hecks.bluebook +124 -0
- data/lib/hecks/hecks/hecks.hecksagon +390 -0
- data/lib/hecks/hecks/hecks.world +42 -0
- data/lib/hecks/kernel_capabilities.rb +194 -0
- data/lib/hecks/language/bluebook/bluebook.bluebook +11 -0
- data/lib/hecks/language/bluebook/query.bluebook +7 -0
- data/lib/hecks/language/bluebook/vocabulary.bluebook +4 -4
- data/lib/hecks/language/hecksagon/domain_port.bluebook +10 -0
- data/lib/hecks/language/hecksagon/hecksagon.bluebook +20 -0
- data/lib/hecks/mcp_stdio_guard.rb +2 -2
- data/lib/hecks/persistence_legacy_fixture/regenerate.rb +161 -0
- data/lib/hecks/persistence_legacy_fixture.rb +113 -0
- data/lib/hecks/ports/persistence/plugins/era/era_check.rb +8 -5
- data/lib/hecks/ports/persistence/plugins/era/era_guard.rb +4 -1
- data/lib/hecks/ports/persistence/plugins/era/expected_era.rb +15 -2
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/era_store.rb +1 -1
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/tail_merge.rb +1 -1
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/merge_coordinator.rb +1 -1
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/minter.rb +32 -15
- data/lib/hecks/ports/persistence/plugins/era/postgres_era.rb +6 -3
- data/lib/hecks/ports/persistence/plugins/era/translation/approval_file.rb +227 -0
- data/lib/hecks/ports/persistence/plugins/era/translation/audit/approval_digest.rb +1 -1
- data/lib/hecks/ports/persistence/plugins/era/translation/reattest.rb +43 -24
- data/lib/hecks/ports/persistence/plugins/era/translation.rb +1 -0
- data/lib/hecks/ports/persistence/state_codec.rb +24 -5
- data/lib/hecks/projection_files.rb +285 -0
- data/lib/hecks/projections/bootstrap_table.rb +1 -1
- data/lib/hecks/projections/deploy/fargate.rb +5 -5
- data/lib/hecks/projections/deploy/lambda.rb +43 -32
- data/lib/hecks/projections/deploy/preview/preview.sh.erb +1 -1
- data/lib/hecks/projections/deploy/preview/template.rb +1 -1
- data/lib/hecks/projections/deploy/scripts/deploy-service.sh.tmpl +1 -1
- data/lib/hecks/projections/deploy/scripts/expected-era.header +1 -1
- data/lib/hecks/projections/deploy/scripts/hosting.mk.tmpl +3 -3
- data/lib/hecks/projections/deploy/scripts/smoke-after-deploy.sh.tmpl +1 -1
- data/lib/hecks/projections/deploy/shared.rb +1 -1
- data/lib/hecks/projections/diagrams.rb +3 -3
- data/lib/hecks/projections/model/deviations.rb +1 -1
- data/lib/hecks/projections/parser_table.rb +4 -4
- data/lib/hecks/projections/reference.rb +2 -3
- data/lib/hecks/projections/rust_vocabulary.rb +1 -1
- data/lib/hecks/projector/cli_projector.rb +106 -37
- data/lib/hecks/projector.rb +1 -1
- data/lib/hecks/quality_control/adapters/agent.adapter +7 -0
- data/lib/hecks/quality_control/adapters/agent.rb +58 -0
- data/lib/hecks/quality_control/adapters/angle_tools.adapter +7 -0
- data/lib/hecks/quality_control/adapters/angle_tools.rb +15 -0
- data/lib/hecks/quality_control/adapters/clearance_recorder.rb +36 -0
- data/lib/hecks/quality_control/adapters/clearance_tools.adapter +7 -0
- data/lib/hecks/quality_control/adapters/clearance_tools.rb +17 -0
- data/lib/hecks/quality_control/adapters/git_pr.adapter +7 -0
- data/lib/hecks/quality_control/adapters/git_pr.rb +226 -0
- data/lib/hecks/quality_control/adapters/github_checks.adapter +9 -0
- data/lib/hecks/quality_control/adapters/github_checks.rb +83 -0
- data/lib/hecks/quality_control/adapters/github_ci_webhook.rb +56 -0
- data/lib/hecks/quality_control/adapters/github_issues.adapter +7 -0
- data/lib/hecks/quality_control/adapters/github_issues.rb +49 -0
- data/lib/hecks/quality_control/adapters/qa_tool.rb +59 -0
- data/lib/hecks/quality_control/adapters/sweep_tools.adapter +7 -0
- data/lib/hecks/quality_control/adapters/sweep_tools.rb +51 -0
- data/lib/hecks/quality_control/adapters/target_tools.adapter +7 -0
- data/lib/hecks/quality_control/adapters/target_tools.rb +43 -0
- data/lib/hecks/quality_control/cli/child.rb +45 -0
- data/lib/hecks/quality_control/cli/qa_concurrency_racer.rb +45 -0
- data/lib/hecks/quality_control/cli/qa_discover_external_domains.rb +248 -0
- data/lib/hecks/quality_control/cli/qa_domain_novelty.rb +163 -0
- data/lib/hecks/quality_control/cli/qa_generated_domains.rb +450 -0
- data/lib/hecks/quality_control/cli/qa_log_bug.rb +195 -0
- data/lib/hecks/quality_control/cli/qa_mine_combinations.rb +220 -0
- data/lib/hecks/quality_control/cli/qa_open_pr.rb +305 -0
- data/lib/hecks/quality_control/cli/qa_postgres_migrate.rb +154 -0
- data/lib/hecks/quality_control/cli/qa_postgres_role.rb +159 -0
- data/lib/hecks/quality_control/cli/qa_pr_check.rb +232 -0
- data/lib/hecks/quality_control/cli/qa_seed_angles.rb +142 -0
- data/lib/hecks/quality_control/cli/qa_seed_targets.rb +69 -0
- data/lib/hecks/quality_control/cli/qa_sweep/all_mode.rb +280 -0
- data/lib/hecks/quality_control/cli/qa_sweep/checks.rb +329 -0
- data/lib/hecks/quality_control/cli/qa_sweep/dials.rb +92 -0
- data/lib/hecks/quality_control/cli/qa_sweep/finding_report.rb +117 -0
- data/lib/hecks/quality_control/cli/qa_sweep/parsing.rb +175 -0
- data/lib/hecks/quality_control/cli/qa_sweep/release_mode.rb +60 -0
- data/lib/hecks/quality_control/cli/qa_sweep.rb +611 -0
- data/lib/hecks/quality_control/cli/qa_tick.rb +274 -0
- data/lib/hecks/quality_control/quality_control.bluebook +2224 -0
- data/lib/hecks/quality_control/quality_control.hecksagon +13 -0
- data/lib/hecks/quality_control/quality_control.ports.hecksagon +54 -0
- data/lib/hecks/query_ir.rb +346 -0
- data/lib/hecks/query_ir_mcp.rb +198 -0
- data/lib/hecks/release/runner/ci_publisher.rb +2 -2
- data/lib/hecks/release/runner/clean_tree.rb +42 -0
- data/lib/hecks/release/runner/console.rb +6 -0
- data/lib/hecks/release/runner/gem_publisher.rb +14 -7
- data/lib/hecks/release/runner/npm_publisher.rb +6 -36
- data/lib/hecks/release/runner/preflight.rb +26 -2
- data/lib/hecks/release/runner/published.rb +8 -26
- data/lib/hecks/release/runner/tagger.rb +30 -15
- data/lib/hecks/release/runner.rb +57 -6
- data/lib/hecks/router/namespace_installer.rb +3 -3
- data/lib/hecks/runtime/adapter_lookup.rb +124 -0
- data/lib/hecks/runtime/command_interpreter/mutation_applier.rb +4 -0
- data/lib/hecks/runtime/dependency_planning.rb +20 -2
- data/lib/hecks/runtime/dispatcher.rb +53 -3
- data/lib/hecks/runtime/interpreting.rb +3 -0
- data/lib/hecks/runtime/loader.rb +65 -12
- data/lib/hecks/runtime/outbox.rb +1 -1
- data/lib/hecks/runtime/policy_interpreter.rb +5 -2
- data/lib/hecks/runtime/port_operation_interpreter.rb +2 -10
- data/lib/hecks/runtime/query_interpreter.rb +77 -0
- data/lib/hecks/runtime/reaction_outcome.rb +89 -0
- data/lib/hecks/runtime/refusal_wording.rb +23 -1
- data/lib/hecks/runtime/registry/verification.rb +163 -4
- data/lib/hecks/runtime/registry.rb +57 -3
- data/lib/hecks/runtime/remote_dispatcher.rb +31 -2
- data/lib/hecks/runtime/value/coercion.rb +23 -135
- data/lib/hecks/runtime/value/field_checks.rb +172 -0
- data/lib/hecks/runtime/value.rb +4 -1
- data/lib/hecks/runtime.rb +20 -6
- data/lib/hecks/rust_build/conformance.rb +158 -0
- data/lib/hecks/rust_build/conformance_fuzz.rb +71 -0
- data/lib/hecks/rust_build/coverage.rb +218 -0
- data/lib/hecks/rust_build/native_build.rb +124 -0
- data/lib/hecks/rust_build/project_rust/root_mod.rb +120 -0
- data/lib/hecks/rust_build/project_rust.rb +332 -0
- data/lib/hecks/rust_build/wasm.rb +105 -0
- data/lib/hecks/rust_build/wasm_browser.rb +113 -0
- data/lib/hecks/rust_build.rb +144 -0
- data/lib/hecks/storehouse.rb +16 -16
- data/lib/hecks/tenancy/bluebook/tenancy.bluebook +2 -2
- data/lib/hecks/tenancy/bluebook/tenancy.hecksagon +1 -1
- data/lib/hecks/three_zero/forms.yml +127 -0
- data/lib/hecks/three_zero.rb +12 -0
- data/lib/hecks/tools/argument_gate_matrix.rb +316 -0
- data/lib/hecks/tools/comment_style.rb +1070 -0
- data/lib/hecks/tools/corpus_report.rb +87 -0
- data/lib/hecks/tools/deploy_recipe.rb +255 -0
- data/lib/hecks/tools/deploy_recipe_lint.rb +282 -0
- data/lib/hecks/tools/drop_implicit_append_fields.rb +95 -0
- data/lib/hecks/tools/era_projection_backfill.rb +94 -0
- data/lib/hecks/tools/era_reattest.rb +146 -0
- data/lib/hecks/tools/evolve_run.rb +189 -0
- data/lib/hecks/tools/fuzz_sweep.rb +373 -0
- data/lib/hecks/tools/heki_compaction.rb +100 -0
- data/lib/hecks/tools/hoist_local_givens.rb +246 -0
- data/lib/hecks/tools/journal_compaction.rb +89 -0
- data/lib/hecks/tools/oidc_manifests.rb +70 -0
- data/lib/hecks/tools/query_ir_run.rb +81 -0
- data/lib/hecks/tools/regeneration_run.rb +166 -0
- data/lib/hecks/tools/rust_comment_style.rb +642 -0
- data/lib/hecks/tools/sequence_script.rb +62 -0
- data/lib/hecks/tools/tail_merge.rb +117 -0
- data/lib/hecks/tools/tenant_provisioning.rb +142 -0
- data/lib/hecks/tools/translation_audit.rb +208 -0
- data/lib/hecks/tools/translation_scaffold.rb +131 -0
- data/lib/hecks/tools.rb +86 -0
- data/lib/hecks/version.rb +1 -1
- data/lib/hecks.rb +32 -15
- data/qa/settings.yml +66 -0
- data/rust/.cargo/config.toml +6 -0
- data/rust/.gitignore +2 -0
- data/rust/Cargo.lock +54 -0
- data/rust/Cargo.toml +61 -0
- data/rust/build/.cargo/config.toml +3 -0
- data/rust/build/.gitignore +1 -0
- data/rust/build/Cargo.lock +7 -0
- data/rust/build/Cargo.toml +19 -0
- data/rust/build/src/build_artifact.rs +47 -0
- data/rust/build/src/cargo_sync.rs +248 -0
- data/rust/build/src/json.rs +421 -0
- data/rust/build/src/lineage_pass.rs +285 -0
- data/rust/build/src/main.rs +51 -0
- data/rust/build/src/optional_pass.rs +150 -0
- data/rust/build/src/pipeline.rs +264 -0
- data/rust/build/src/reserved_names.rs +76 -0
- data/rust/build/src/resolve.rs +124 -0
- data/rust/build/src/root.rs +25 -0
- data/rust/build/src/sidecars.rs +73 -0
- data/rust/build/src/subprocess.rs +51 -0
- data/rust/build/src/tmp.rs +31 -0
- data/rust/codegen/.cargo/config.toml +3 -0
- data/rust/codegen/.gitignore +1 -0
- data/rust/codegen/Cargo.lock +7 -0
- data/rust/codegen/Cargo.toml +20 -0
- data/rust/codegen/src/attr.rs +32 -0
- data/rust/codegen/src/bridging.rs +481 -0
- data/rust/codegen/src/commands.rs +1226 -0
- data/rust/codegen/src/constraints.rs +109 -0
- data/rust/codegen/src/dependency_planning.rs +209 -0
- data/rust/codegen/src/domain_generator.rs +1288 -0
- data/rust/codegen/src/exemplar.rs +230 -0
- data/rust/codegen/src/expr_emitter.rs +87 -0
- data/rust/codegen/src/fielded.rs +192 -0
- data/rust/codegen/src/hecks_naming.rs +33 -0
- data/rust/codegen/src/json.rs +335 -0
- data/rust/codegen/src/json_codec.rs +697 -0
- data/rust/codegen/src/literal.rs +207 -0
- data/rust/codegen/src/main.rs +290 -0
- data/rust/codegen/src/manifest.rs +227 -0
- data/rust/codegen/src/mutations.rs +784 -0
- data/rust/codegen/src/naming.rs +286 -0
- data/rust/codegen/src/ports.rs +208 -0
- data/rust/codegen/src/prelude.rs +116 -0
- data/rust/codegen/src/queries.rs +786 -0
- data/rust/codegen/src/reactions.rs +532 -0
- data/rust/codegen/src/read_models.rs +697 -0
- data/rust/codegen/src/reference_specs.rs +40 -0
- data/rust/codegen/src/registry.rs +966 -0
- data/rust/codegen/src/reserved_names.rs +76 -0
- data/rust/codegen/src/shared.rs +93 -0
- data/rust/codegen/src/skip_reason.rs +25 -0
- data/rust/codegen/src/types.rs +362 -0
- data/rust/host/.cargo/config.toml +3 -0
- data/rust/host/.gitignore +1 -0
- data/rust/host/Cargo.lock +4297 -0
- data/rust/host/Cargo.toml +61 -0
- data/rust/host/HECKS_RELEASE +1 -0
- data/rust/host/rds-ca-bundle.pem +2736 -0
- data/rust/host/src/api.rs +1596 -0
- data/rust/host/src/approval.rs +652 -0
- data/rust/host/src/auth.rs +1287 -0
- data/rust/host/src/bin/expr_harness.rs +46 -0
- data/rust/host/src/bin/lineage_harness.rs +119 -0
- data/rust/host/src/bin/mint_harness.rs +135 -0
- data/rust/host/src/checkout.rs +265 -0
- data/rust/host/src/dispatch.rs +1443 -0
- data/rust/host/src/expr_json.rs +1117 -0
- data/rust/host/src/field_hints.rs +52 -0
- data/rust/host/src/ir.rs +733 -0
- data/rust/host/src/journal.rs +874 -0
- data/rust/host/src/lambda_client.rs +378 -0
- data/rust/host/src/log.rs +147 -0
- data/rust/host/src/main.rs +474 -0
- data/rust/host/src/mint.rs +1865 -0
- data/rust/host/src/payments/keystore.rs +330 -0
- data/rust/host/src/payments/tests.rs +1400 -0
- data/rust/host/src/payments.rs +767 -0
- data/rust/host/src/presentation.rs +698 -0
- data/rust/host/src/presentation_write.rs +1431 -0
- data/rust/host/src/query_step.rs +149 -0
- data/rust/host/src/rate_limit.rs +904 -0
- data/rust/host/src/reference_transform.rs +241 -0
- data/rust/host/src/reference_validate.rs +338 -0
- data/rust/host/src/resend.rs +296 -0
- data/rust/host/src/secrets.rs +106 -0
- data/rust/host/src/server.rs +663 -0
- data/rust/host/src/storage_shape.rs +175 -0
- data/rust/host/src/test_pg.rs +40 -0
- data/rust/host/src/ui_schema.rs +1351 -0
- data/rust/host/src/wasm_runner.rs +191 -0
- data/rust/host/src/web/newsletter/cooldown.rs +126 -0
- data/rust/host/src/web/newsletter/tests.rs +104 -0
- data/rust/host/src/web/newsletter.rs +310 -0
- data/rust/host/src/web/newsletter_send.rs +357 -0
- data/rust/host/src/web/registrations.rs +1505 -0
- data/rust/host/src/web.rs +2937 -0
- data/rust/lsp/.gitignore +1 -0
- data/rust/lsp/Cargo.lock +7 -0
- data/rust/lsp/Cargo.toml +16 -0
- data/rust/lsp/README.md +133 -0
- data/rust/lsp/src/diagnostics.rs +165 -0
- data/rust/lsp/src/json.rs +385 -0
- data/rust/lsp/src/main.rs +399 -0
- data/rust/lsp/src/outline.rs +177 -0
- data/rust/lsp/src/rpc.rs +67 -0
- data/rust/parser/.cargo/config.toml +5 -0
- data/rust/parser/.gitignore +1 -0
- data/rust/parser/Cargo.lock +7 -0
- data/rust/parser/Cargo.toml +21 -0
- data/rust/parser/src/build/closed_sets.rs +27 -0
- data/rust/parser/src/build/identity.rs +276 -0
- data/rust/parser/src/build/mod.rs +12 -0
- data/rust/parser/src/build/naming.rs +141 -0
- data/rust/parser/src/build/pattern_subset.rs +161 -0
- data/rust/parser/src/build/query_derive.rs +72 -0
- data/rust/parser/src/build/query_inference.rs +269 -0
- data/rust/parser/src/build/query_options.rs +100 -0
- data/rust/parser/src/build/read_model.rs +94 -0
- data/rust/parser/src/build/references.rs +32 -0
- data/rust/parser/src/canonical.rs +114 -0
- data/rust/parser/src/diag.rs +55 -0
- data/rust/parser/src/emit.rs +953 -0
- data/rust/parser/src/expr/ast_json.rs +147 -0
- data/rust/parser/src/expr/evaluator.rs +81 -0
- data/rust/parser/src/expr/mod.rs +84 -0
- data/rust/parser/src/expr/resolver.rs +497 -0
- data/rust/parser/src/ir.rs +387 -0
- data/rust/parser/src/keywords.rs +585 -0
- data/rust/parser/src/lex.rs +662 -0
- data/rust/parser/src/main.rs +252 -0
- data/rust/parser/src/parse/aggregate.rs +612 -0
- data/rust/parser/src/parse/chapter.rs +517 -0
- data/rust/parser/src/parse/command.rs +637 -0
- data/rust/parser/src/parse/domain_port.rs +127 -0
- data/rust/parser/src/parse/entity.rs +527 -0
- data/rust/parser/src/parse/file.rs +67 -0
- data/rust/parser/src/parse/hecksagon.rs +231 -0
- data/rust/parser/src/parse/lifecycle.rs +162 -0
- data/rust/parser/src/parse/mod.rs +1262 -0
- data/rust/parser/src/parse/policy.rs +87 -0
- data/rust/parser/src/parse/process_manager.rs +305 -0
- data/rust/parser/src/parse/query.rs +100 -0
- data/rust/parser/src/parse/read_model.rs +214 -0
- data/rust/parser/src/parse/value_object.rs +250 -0
- data/rust/parser/src/ruby_value.rs +422 -0
- data/rust/project/bridging.rb +257 -0
- data/rust/project/commands.rb +877 -0
- data/rust/project/constraints.rb +86 -0
- data/rust/project/dependency_planning.rb +178 -0
- data/rust/project/domain_generator.rb +980 -0
- data/rust/project/exemplar.rb +148 -0
- data/rust/project/expr_emitter.rb +72 -0
- data/rust/project/fielded.rb +141 -0
- data/rust/project/json_codec.rb +641 -0
- data/rust/project/mutations.rb +721 -0
- data/rust/project/naming.rb +205 -0
- data/rust/project/ports.rb +65 -0
- data/rust/project/queries.rb +493 -0
- data/rust/project/reactions.rb +317 -0
- data/rust/project/read_models.rb +690 -0
- data/rust/project/reference_specs.rb +27 -0
- data/rust/project/registry.rb +396 -0
- data/rust/project/skip_reason.rb +29 -0
- data/rust/project/types.rb +225 -0
- data/rust/project/write_if_changed.rb +83 -0
- data/rust/project.rb +30 -0
- data/rust/project_rust_pipeline.rb +383 -0
- data/rust/src/exemplar/commands.rs +279 -0
- data/rust/src/exemplar/constraints.rs +18 -0
- data/rust/src/exemplar/fielded.rs +280 -0
- data/rust/src/exemplar/json.rs +277 -0
- data/rust/src/exemplar/mod.rs +17 -0
- data/rust/src/exemplar/mutations.rs +80 -0
- data/rust/src/exemplar/queries.rs +33 -0
- data/rust/src/exemplar/reactions.rs +88 -0
- data/rust/src/exemplar/read_models.rs +48 -0
- data/rust/src/exemplar/registry.rs +166 -0
- data/rust/src/exemplar/types.rs +44 -0
- data/rust/src/kernel/attribute_shapes/composite.rs +88 -0
- data/rust/src/kernel/attribute_shapes/list.rs +18 -0
- data/rust/src/kernel/attribute_shapes/mod.rs +44 -0
- data/rust/src/kernel/attribute_shapes/optional.rs +9 -0
- data/rust/src/kernel/attribute_shapes/scalar.rs +39 -0
- data/rust/src/kernel/cli.rs +585 -0
- data/rust/src/kernel/dispatch.rs +1090 -0
- data/rust/src/kernel/expr.rs +458 -0
- data/rust/src/kernel/expression_operators/arithmetic.rs +128 -0
- data/rust/src/kernel/expression_operators/comparison.rs +48 -0
- data/rust/src/kernel/expression_operators/enumeration.rs +232 -0
- data/rust/src/kernel/expression_operators/logical.rs +13 -0
- data/rust/src/kernel/expression_operators/membership.rs +18 -0
- data/rust/src/kernel/expression_operators/mod.rs +68 -0
- data/rust/src/kernel/expression_operators/pattern_match.rs +73 -0
- data/rust/src/kernel/expression_operators/positional.rs +126 -0
- data/rust/src/kernel/expression_operators/presence.rs +101 -0
- data/rust/src/kernel/expression_operators/sign_test.rs +16 -0
- data/rust/src/kernel/expression_operators/sized.rs +35 -0
- data/rust/src/kernel/expression_operators/text.rs +79 -0
- data/rust/src/kernel/expression_operators/to_string.rs +18 -0
- data/rust/src/kernel/json.rs +779 -0
- data/rust/src/kernel/mod.rs +133 -0
- data/rust/src/kernel/named_query.rs +219 -0
- data/rust/src/kernel/orchestrate.rs +1810 -0
- data/rust/src/kernel/pattern.rs +307 -0
- data/rust/src/kernel/query_comparators.rs +206 -0
- data/rust/src/kernel/query_ordering.rs +103 -0
- data/rust/src/kernel/read_model.rs +914 -0
- data/rust/src/kernel/reference_lookup.rs +149 -0
- data/rust/src/kernel/repository.rs +468 -0
- data/rust/src/kernel/routing.rs +612 -0
- data/rust/src/kernel/vocab/aggregate_dispatch_order.rs +118 -0
- data/rust/src/kernel/vocab/entity_dispatch_order.rs +114 -0
- data/rust/src/kernel/vocab/field_hint.rs +70 -0
- data/rust/src/kernel/vocab/mod.rs +20 -0
- data/rust/src/kernel/vocab/query_comparator.rs +65 -0
- data/rust/src/kernel/vocab/refusal_template.rs +1592 -0
- data/rust/src/lib.rs +8 -0
- data/rust/src/main.rs +16 -0
- data/rust/web/.gitignore +1 -0
- data/rust/web/Cargo.lock +119 -0
- data/rust/web/Cargo.toml +18 -0
- data/rust/web/src/lib.rs +8 -0
- metadata +477 -12
- data/lib/hecks/facade/cli_door.rb +0 -96
- data/lib/hecks/facade/cli_runner.rb +0 -127
- data/lib/hecks/mcp_door_scope.rb +0 -95
|
@@ -0,0 +1,1505 @@
|
|
|
1
|
+
use super::{instances_for, last_refusal, respond, with_id};
|
|
2
|
+
use crate::checkout;
|
|
3
|
+
use crate::dispatch;
|
|
4
|
+
use crate::ir::{payments_provider, PaymentsProvider};
|
|
5
|
+
use crate::journal::LineageConfig;
|
|
6
|
+
use crate::lambda_client::LambdaInvoker;
|
|
7
|
+
use crate::payments;
|
|
8
|
+
use serde_json::{json, Value};
|
|
9
|
+
use std::path::Path;
|
|
10
|
+
use tokio::sync::Mutex;
|
|
11
|
+
use tokio_postgres::Client;
|
|
12
|
+
|
|
13
|
+
// Routes for /events, /registrations and /webhooks/stripe. Event and
|
|
14
|
+
// Registration names are fixed here rather than read from the IR.
|
|
15
|
+
|
|
16
|
+
/// Whether checkout is on: `configured` must be set and equal `domain`
|
|
17
|
+
/// exactly — a mismatch would dispatch the wrong domain's Registration.Request.
|
|
18
|
+
pub(crate) fn checkout_enabled(configured: Option<&str>, domain: &str) -> bool {
|
|
19
|
+
configured.is_some_and(|c| !c.is_empty() && c == domain)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Fixed, publicly-known, non-secret mock webhook secret; used only while
|
|
23
|
+
// STRIPE_WEBHOOK_SECRET is unset, so a mock deploy needs no secret to be
|
|
24
|
+
// exercisable end to end.
|
|
25
|
+
pub(crate) const MOCK_STRIPE_WEBHOOK_SECRET: &str = "whsec_mock_checkout_fixed";
|
|
26
|
+
|
|
27
|
+
// The processor a Payment reports, as Payment.Initiate recorded it. Read off
|
|
28
|
+
// the Payment itself, not the connection now in effect — the connection can
|
|
29
|
+
// change between a guest registering and the webhook arriving.
|
|
30
|
+
fn payment_processor(read: &Value, payments: &PaymentsProvider, reference: &str) -> Option<String> {
|
|
31
|
+
instances_for(read, &payments.instance_prefix())
|
|
32
|
+
.into_iter()
|
|
33
|
+
.find(|(id, _)| id == reference)
|
|
34
|
+
.and_then(|(_, payment)| payment.get("processor").and_then(|p| p.get("value")).and_then(|v| v.as_str()).map(String::from))
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/// Payment statuses whose registration still holds a seat. A registration
|
|
38
|
+
/// with no Payment, or one that's archived, holds none regardless of status.
|
|
39
|
+
const SEAT_HOLDING_PAYMENT_STATUSES: [&str; 4] = ["pending", "succeeded", "refunding", "disputed"];
|
|
40
|
+
|
|
41
|
+
/// The Registration lifecycle state that gives its seat back.
|
|
42
|
+
const ARCHIVED_REGISTRATION_STATUS: &str = "archived";
|
|
43
|
+
|
|
44
|
+
/// Whether a registration's own lifecycle has archived it. A missing or
|
|
45
|
+
/// non-text `status` is active.
|
|
46
|
+
fn registration_archived(registration: &Value) -> bool {
|
|
47
|
+
registration.get("status").and_then(|s| s.as_str()) == Some(ARCHIVED_REGISTRATION_STATUS)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/// A reference to an aggregate reads back as its plain id, or wrapped as
|
|
51
|
+
/// `{"value": id}`; an empty id is no reference.
|
|
52
|
+
fn plain_id(raw: Option<&Value>) -> Option<String> {
|
|
53
|
+
let raw = raw?;
|
|
54
|
+
let value = raw.get("value").unwrap_or(raw);
|
|
55
|
+
value.as_str().filter(|id| !id.is_empty()).map(String::from)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/// Seats used per event slug: non-archived Registrations whose Payment (same
|
|
59
|
+
/// reference) is in a seat-holding status.
|
|
60
|
+
fn seat_counts(read: &Value, domain: &str, payments: &PaymentsProvider) -> std::collections::HashMap<String, usize> {
|
|
61
|
+
let statuses: std::collections::HashMap<String, String> = instances_for(read, &payments.instance_prefix())
|
|
62
|
+
.into_iter()
|
|
63
|
+
.filter_map(|(id, payment)| payment.get("status").and_then(|s| s.as_str()).map(|s| (id, s.to_string())))
|
|
64
|
+
.collect();
|
|
65
|
+
let mut counts = std::collections::HashMap::new();
|
|
66
|
+
for (id, registration) in instances_for(read, &crate::ir::registrations_binding(domain).registration_prefix()) {
|
|
67
|
+
let holds = statuses.get(&id).is_some_and(|status| SEAT_HOLDING_PAYMENT_STATUSES.contains(&status.as_str()));
|
|
68
|
+
if let (true, false, Some(event_slug)) = (holds, registration_archived(®istration), plain_id(registration.get("event_slug"))) {
|
|
69
|
+
*counts.entry(event_slug).or_insert(0) += 1;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
counts
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/// How many seats one event has used.
|
|
76
|
+
pub(crate) fn seats_taken(read: &Value, domain: &str, payments: &PaymentsProvider, event_slug: &str) -> usize {
|
|
77
|
+
seat_counts(read, domain, payments).get(event_slug).copied().unwrap_or(0)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/// An event's capacity, when it carries a readable one.
|
|
81
|
+
fn event_capacity(event: &Value) -> Option<i64> {
|
|
82
|
+
event.get("capacity").and_then(|c| c.get("value")).and_then(|v| v.as_i64())
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/// Seats still open given `taken`, never below zero.
|
|
86
|
+
fn open_seats(capacity: i64, taken: usize) -> i64 {
|
|
87
|
+
(capacity - taken as i64).max(0)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/// Seats still open on one event, never below zero; `None` when there is no
|
|
91
|
+
/// such event or it carries no readable capacity.
|
|
92
|
+
pub(crate) fn seats_left(read: &Value, domain: &str, payments: &PaymentsProvider, event_slug: &str) -> Option<i64> {
|
|
93
|
+
let events = instances_for(read, &crate::ir::registrations_binding(domain).event_prefix());
|
|
94
|
+
let (_, event) = events.iter().find(|(id, _)| id == event_slug)?;
|
|
95
|
+
Some(open_seats(event_capacity(event)?, seats_taken(read, domain, payments, event_slug)))
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/// The seat figures a caller reads for one event: `capacity` and `seats_left`
|
|
99
|
+
/// are `null` when the event carries no readable capacity.
|
|
100
|
+
fn seat_figures(event: &Value, taken: usize) -> Value {
|
|
101
|
+
let capacity = event_capacity(event);
|
|
102
|
+
json!({"capacity": capacity, "seats_taken": taken, "seats_left": capacity.map(|c| open_seats(c, taken))})
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/// The seat figures of every event, keyed by event slug.
|
|
106
|
+
fn all_seat_figures(read: &Value, domain: &str, payments: &PaymentsProvider) -> Value {
|
|
107
|
+
let counts = seat_counts(read, domain, payments);
|
|
108
|
+
let events = instances_for(read, &crate::ir::registrations_binding(domain).event_prefix());
|
|
109
|
+
let figures: serde_json::Map<String, Value> =
|
|
110
|
+
events.iter().map(|(slug, event)| (slug.clone(), seat_figures(event, counts.get(slug).copied().unwrap_or(0)))).collect();
|
|
111
|
+
json!({"events": figures})
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/// The seat figures of one event, with its `slug`; `None` when there is no such event.
|
|
115
|
+
fn one_seat_figures(read: &Value, domain: &str, payments: &PaymentsProvider, event_slug: &str) -> Option<Value> {
|
|
116
|
+
let events = instances_for(read, &crate::ir::registrations_binding(domain).event_prefix());
|
|
117
|
+
let (_, event) = events.iter().find(|(id, _)| id == event_slug)?;
|
|
118
|
+
let mut figures = seat_figures(event, seats_taken(read, domain, payments, event_slug));
|
|
119
|
+
figures["slug"] = json!(event_slug);
|
|
120
|
+
Some(figures)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/// The slug in `/events/<slug>/seats`; a slug is one non-empty path segment.
|
|
124
|
+
fn seats_path_slug(path: &str) -> Option<&str> {
|
|
125
|
+
let slug = path.strip_prefix("/events/")?.strip_suffix("/seats")?;
|
|
126
|
+
(!slug.is_empty() && !slug.contains('/')).then_some(slug)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/// GET /events/seats and GET /events/<slug>/seats: the seat rule as a read,
|
|
130
|
+
/// so a site never has to count registrations itself.
|
|
131
|
+
async fn seats_route(path: &str, client: &Mutex<Client>, wasm_path: &Path, config: &LineageConfig, payments: &PaymentsProvider) -> Value {
|
|
132
|
+
let read = match dispatch::read(client, wasm_path).await {
|
|
133
|
+
Ok(r) => r,
|
|
134
|
+
Err(e) => return respond(500, "text/plain", &format!("{e:#}")),
|
|
135
|
+
};
|
|
136
|
+
let body = match seats_path_slug(path) {
|
|
137
|
+
None => all_seat_figures(&read, &config.domain, payments),
|
|
138
|
+
Some(slug) => match one_seat_figures(&read, &config.domain, payments, slug) {
|
|
139
|
+
Some(figures) => figures,
|
|
140
|
+
None => return respond(404, "application/json", &json!({"error": "no such event"}).to_string()),
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
respond(200, "application/json", &body.to_string())
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
fn unix_now() -> i64 {
|
|
147
|
+
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs() as i64
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
#[allow(clippy::too_many_arguments)]
|
|
151
|
+
// Serves the checkout, registration-payment and webhook routes only when the
|
|
152
|
+
// domain's IR declares `provides "payments"`. Takes the IR as a parameter,
|
|
153
|
+
// not `ir()`, so the gate can be tested against one that lacks the key.
|
|
154
|
+
#[allow(clippy::too_many_arguments)]
|
|
155
|
+
pub(super) async fn payments_routes(
|
|
156
|
+
domain_ir: Option<&Value>,
|
|
157
|
+
method: &str,
|
|
158
|
+
path: &str,
|
|
159
|
+
raw_body: &str,
|
|
160
|
+
stripe_signature: &str,
|
|
161
|
+
client: &Mutex<Client>,
|
|
162
|
+
wasm_path: &Path,
|
|
163
|
+
config: &LineageConfig,
|
|
164
|
+
invoker: &dyn LambdaInvoker,
|
|
165
|
+
) -> Option<Value> {
|
|
166
|
+
let payments = domain_ir.and_then(payments_provider)?;
|
|
167
|
+
checkout_route(method, path, raw_body, stripe_signature, client, wasm_path, config, invoker, &payments).await
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
pub(super) async fn checkout_route(
|
|
171
|
+
method: &str,
|
|
172
|
+
path: &str,
|
|
173
|
+
raw_body: &str,
|
|
174
|
+
stripe_signature: &str,
|
|
175
|
+
client: &Mutex<Client>,
|
|
176
|
+
wasm_path: &Path,
|
|
177
|
+
config: &LineageConfig,
|
|
178
|
+
invoker: &dyn LambdaInvoker,
|
|
179
|
+
payments: &PaymentsProvider,
|
|
180
|
+
) -> Option<Value> {
|
|
181
|
+
match (method, path) {
|
|
182
|
+
("POST", "/registrations") => {
|
|
183
|
+
Some(registrations_route(raw_body, &payments::PlatformConfig::load().await, client, wasm_path, config, invoker, payments).await)
|
|
184
|
+
}
|
|
185
|
+
// GET /registrations/:id: read-only, re-derives state server-side
|
|
186
|
+
// for the confirmation and pay pages rather than trusting their own
|
|
187
|
+
// query string.
|
|
188
|
+
("GET", path) if path.starts_with("/registrations/") && !path.ends_with("/complete") => {
|
|
189
|
+
let registration_id = path.trim_start_matches("/registrations/");
|
|
190
|
+
Some(registration_show_route(registration_id, client, wasm_path, config, payments).await)
|
|
191
|
+
}
|
|
192
|
+
// POST /registrations/:id/complete: mock checkout's Pay/Cancel button,
|
|
193
|
+
// refused unless the Payment's own processor is "mock_stripe". Settles
|
|
194
|
+
// through the same PaymentGateway port as POST /webhooks/stripe.
|
|
195
|
+
("POST", path) if path.starts_with("/registrations/") && path.ends_with("/complete") => {
|
|
196
|
+
let registration_id = path.trim_start_matches("/registrations/").trim_end_matches("/complete").trim_end_matches('/');
|
|
197
|
+
Some(registration_complete_route(registration_id, raw_body, client, wasm_path, config, invoker, payments).await)
|
|
198
|
+
}
|
|
199
|
+
("POST", "/webhooks/stripe") => {
|
|
200
|
+
Some(webhook_route(raw_body, stripe_signature, &payments::PlatformConfig::load().await, client, wasm_path, config, invoker, payments).await)
|
|
201
|
+
}
|
|
202
|
+
// POST /events: a mock-payments service schedules a new session
|
|
203
|
+
// when an editor picks a date. Idempotent — Event.find first means
|
|
204
|
+
// a repeat call for the same slug is a no-op, not an error.
|
|
205
|
+
("GET", "/events/seats") => Some(seats_route(path, client, wasm_path, config, payments).await),
|
|
206
|
+
("GET", path) if seats_path_slug(path).is_some() => Some(seats_route(path, client, wasm_path, config, payments).await),
|
|
207
|
+
("POST", "/events") => Some(events_route(raw_body, client, wasm_path, config, invoker).await),
|
|
208
|
+
_ => None,
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/// POST /events. Dispatches `<domain>::Event.Schedule` — Event is this
|
|
213
|
+
/// deploy's own top-level aggregate, not a vendored chapter like Payments.
|
|
214
|
+
async fn events_route(raw_body: &str, client: &Mutex<Client>, wasm_path: &Path, config: &LineageConfig, invoker: &dyn LambdaInvoker) -> Value {
|
|
215
|
+
let body: Value = match serde_json::from_str(raw_body) {
|
|
216
|
+
Ok(v) => v,
|
|
217
|
+
Err(e) => return respond(400, "application/json", &json!({"error": format!("invalid JSON: {e}")}).to_string()),
|
|
218
|
+
};
|
|
219
|
+
let Some(slug) = body.get("slug").and_then(|v| v.as_str()) else {
|
|
220
|
+
return respond(400, "application/json", &json!({"error": "missing slug"}).to_string());
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
let read = match dispatch::read(client, wasm_path).await {
|
|
224
|
+
Ok(r) => r,
|
|
225
|
+
Err(e) => return respond(500, "text/plain", &format!("{e:#}")),
|
|
226
|
+
};
|
|
227
|
+
let events = instances_for(&read, &crate::ir::registrations_binding(&config.domain).event_prefix());
|
|
228
|
+
if let Some((_, existing)) = events.iter().find(|(id, _)| id == slug) {
|
|
229
|
+
return respond(200, "application/json", &serde_json::to_string_pretty(&with_id(slug, existing)).unwrap_or_default());
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
let Some(name) = body.get("name").and_then(|v| v.as_str()) else {
|
|
233
|
+
return respond(400, "application/json", &json!({"error": "missing name"}).to_string());
|
|
234
|
+
};
|
|
235
|
+
let Some(price_cents) = body.get("price_cents").and_then(|v| v.as_i64()) else {
|
|
236
|
+
return respond(400, "application/json", &json!({"error": "missing price_cents"}).to_string());
|
|
237
|
+
};
|
|
238
|
+
let Some(capacity) = body.get("capacity").and_then(|v| v.as_i64()) else {
|
|
239
|
+
return respond(400, "application/json", &json!({"error": "missing capacity"}).to_string());
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
let args = json!({
|
|
243
|
+
"slug": {"value": slug},
|
|
244
|
+
"name": {"value": name},
|
|
245
|
+
"price": {"cents": price_cents},
|
|
246
|
+
"capacity": {"value": capacity},
|
|
247
|
+
});
|
|
248
|
+
let verb = crate::ir::registrations_binding(&config.domain).schedule;
|
|
249
|
+
let outcome = match dispatch::handle(client, wasm_path, &verb, args, None, config, invoker).await {
|
|
250
|
+
Ok(o) => o,
|
|
251
|
+
Err(e) => return respond(500, "text/plain", &format!("{e:#}")),
|
|
252
|
+
};
|
|
253
|
+
if !outcome.accepted {
|
|
254
|
+
return respond(422, "application/json", &last_refusal(&outcome.result).to_string());
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
let read = match dispatch::read(client, wasm_path).await {
|
|
258
|
+
Ok(r) => r,
|
|
259
|
+
Err(e) => return respond(500, "text/plain", &format!("{e:#}")),
|
|
260
|
+
};
|
|
261
|
+
let events = instances_for(&read, &crate::ir::registrations_binding(&config.domain).event_prefix());
|
|
262
|
+
let Some((_, event)) = events.iter().find(|(id, _)| id == slug) else {
|
|
263
|
+
return respond(500, "text/plain", "event vanished immediately after being scheduled");
|
|
264
|
+
};
|
|
265
|
+
respond(201, "application/json", &serde_json::to_string_pretty(&with_id(slug, event)).unwrap_or_default())
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/// GET /registrations/:id: event and attendee fields, plus amount_cents and
|
|
269
|
+
/// payment_status from the same-reference Payment.
|
|
270
|
+
async fn registration_show_route(registration_id: &str, client: &Mutex<Client>, wasm_path: &Path, config: &LineageConfig, payments: &PaymentsProvider) -> Value {
|
|
271
|
+
let read = match dispatch::read(client, wasm_path).await {
|
|
272
|
+
Ok(r) => r,
|
|
273
|
+
Err(e) => return respond(500, "text/plain", &format!("{e:#}")),
|
|
274
|
+
};
|
|
275
|
+
let registrations = instances_for(&read, &crate::ir::registrations_binding(&config.domain).registration_prefix());
|
|
276
|
+
let Some((_, registration)) = registrations.iter().find(|(id, _)| id == registration_id) else {
|
|
277
|
+
return respond(404, "application/json", &json!({"error": "no such registration"}).to_string());
|
|
278
|
+
};
|
|
279
|
+
let event_slug = registration.get("event_slug").and_then(|v| v.as_str());
|
|
280
|
+
let events = instances_for(&read, &crate::ir::registrations_binding(&config.domain).event_prefix());
|
|
281
|
+
let event = event_slug.and_then(|slug| events.iter().find(|(id, _)| id == slug)).map(|(_, e)| e);
|
|
282
|
+
|
|
283
|
+
let payment_instances = instances_for(&read, &payments.instance_prefix());
|
|
284
|
+
let payment = payment_instances.iter().find(|(id, _)| id == registration_id).map(|(_, p)| p);
|
|
285
|
+
|
|
286
|
+
let attendee = registration.get("attendee").cloned().unwrap_or_else(|| json!({}));
|
|
287
|
+
respond(200, "application/json", &json!({
|
|
288
|
+
"registration_id": registration_id,
|
|
289
|
+
"event": {
|
|
290
|
+
"slug": event_slug,
|
|
291
|
+
"name": event.and_then(|e| e.get("name")).and_then(|n| n.get("value")).and_then(|v| v.as_str()),
|
|
292
|
+
},
|
|
293
|
+
"attendee": {
|
|
294
|
+
"first_name": attendee.get("first_name"),
|
|
295
|
+
"last_name": attendee.get("last_name"),
|
|
296
|
+
"email": attendee.get("email"),
|
|
297
|
+
"phone": attendee.get("phone"),
|
|
298
|
+
},
|
|
299
|
+
"amount_cents": payment.and_then(|p| p.get("amount")).and_then(|a| a.get("cents")),
|
|
300
|
+
"payment_status": payment.and_then(|p| p.get("status")),
|
|
301
|
+
}).to_string())
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/// POST /registrations/:id/complete. Refused unless the Payment itself was
|
|
305
|
+
/// initiated on "mock_stripe"; settles through the same PaymentGateway port webhook_route uses.
|
|
306
|
+
pub(crate) async fn registration_complete_route(
|
|
307
|
+
registration_id: &str,
|
|
308
|
+
raw_body: &str,
|
|
309
|
+
client: &Mutex<Client>,
|
|
310
|
+
wasm_path: &Path,
|
|
311
|
+
config: &LineageConfig,
|
|
312
|
+
invoker: &dyn LambdaInvoker,
|
|
313
|
+
payments: &PaymentsProvider,
|
|
314
|
+
) -> Value {
|
|
315
|
+
let body: Value = match serde_json::from_str(raw_body) {
|
|
316
|
+
Ok(v) => v,
|
|
317
|
+
Err(e) => return respond(400, "application/json", &json!({"error": format!("invalid JSON: {e}")}).to_string()),
|
|
318
|
+
};
|
|
319
|
+
let Some(outcome) = body.get("outcome").and_then(|v| v.as_str()) else {
|
|
320
|
+
return respond(400, "application/json", &json!({"error": "missing outcome"}).to_string());
|
|
321
|
+
};
|
|
322
|
+
if outcome != "succeeded" && outcome != "failed" {
|
|
323
|
+
return respond(400, "application/json", &json!({"error": "outcome must be \"succeeded\" or \"failed\""}).to_string());
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
let read = match dispatch::read(client, wasm_path).await {
|
|
327
|
+
Ok(r) => r,
|
|
328
|
+
Err(e) => return respond(500, "text/plain", &format!("{e:#}")),
|
|
329
|
+
};
|
|
330
|
+
let registrations = instances_for(&read, &crate::ir::registrations_binding(&config.domain).registration_prefix());
|
|
331
|
+
if !registrations.iter().any(|(id, _)| id == registration_id) {
|
|
332
|
+
return respond(404, "application/json", &json!({"error": "no such registration"}).to_string());
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
let processor = match payment_processor(&read, payments, registration_id) {
|
|
336
|
+
Some(processor) if processor == "mock_stripe" => processor,
|
|
337
|
+
_ => return respond(403, "application/json", &json!({"error": "not available with a real payment processor"}).to_string()),
|
|
338
|
+
};
|
|
339
|
+
let reported_processor = json!({"value": processor});
|
|
340
|
+
// `reference_to Payment, as: :reference` keeps `reference` inside `with:`
|
|
341
|
+
// too — `to:` alone isn't enough, the argument parser still expects it
|
|
342
|
+
// present in facts or it TypeMismatches.
|
|
343
|
+
let reference_fact = json!({"value": registration_id});
|
|
344
|
+
let (verb, facts) = if outcome == "succeeded" {
|
|
345
|
+
(
|
|
346
|
+
payments.succeeded.as_str(),
|
|
347
|
+
json!({"reference": reference_fact, "transaction_id": {"value": format!("local_{}", uuid::Uuid::new_v4().simple())}, "reported_processor": reported_processor}),
|
|
348
|
+
)
|
|
349
|
+
} else {
|
|
350
|
+
(
|
|
351
|
+
payments.failed.as_str(),
|
|
352
|
+
json!({"reference": reference_fact, "reason": {"value": "declined_at_local_checkout"}, "reported_processor": reported_processor}),
|
|
353
|
+
)
|
|
354
|
+
};
|
|
355
|
+
let outcome_result = match dispatch::handle_routed(client, wasm_path, verb, json!(registration_id), facts, None, config, invoker).await {
|
|
356
|
+
Ok(o) => o,
|
|
357
|
+
Err(e) => return respond(500, "text/plain", &format!("{e:#}")),
|
|
358
|
+
};
|
|
359
|
+
// A repeat Succeeded/Failed on an already-settled Payment is a silent
|
|
360
|
+
// no-op on Payment's own `given`, so it isn't surfaced as a 422 here
|
|
361
|
+
// either.
|
|
362
|
+
if !outcome_result.accepted {
|
|
363
|
+
let refusal = last_refusal(&outcome_result.result);
|
|
364
|
+
let already_settled = refusal.get("error").and_then(|v| v.as_str()).map(|s| s.contains("pending")).unwrap_or(false);
|
|
365
|
+
if !already_settled {
|
|
366
|
+
return respond(422, "application/json", &refusal.to_string());
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
let read = match dispatch::read(client, wasm_path).await {
|
|
371
|
+
Ok(r) => r,
|
|
372
|
+
Err(e) => return respond(500, "text/plain", &format!("{e:#}")),
|
|
373
|
+
};
|
|
374
|
+
let payment_instances = instances_for(&read, &payments.instance_prefix());
|
|
375
|
+
let status = payment_instances.iter().find(|(id, _)| id == registration_id).and_then(|(_, p)| p.get("status")).and_then(|v| v.as_str()).unwrap_or("");
|
|
376
|
+
respond(200, "application/json", &json!({"registration_id": registration_id, "payment_status": status}).to_string())
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// Attendee shape varies per domain, so this forwards the caller's fields
|
|
380
|
+
// through verbatim rather than hardcoding one shape; each domain's own
|
|
381
|
+
// given/invariant checks remain the real validation. Only `event_slug` and
|
|
382
|
+
// `return_to` (routing metadata, never part of Attendee) are stripped.
|
|
383
|
+
fn attendee_from(body: &Value) -> Value {
|
|
384
|
+
let mut attendee = body.clone();
|
|
385
|
+
if let Some(object) = attendee.as_object_mut() {
|
|
386
|
+
object.remove("event_slug");
|
|
387
|
+
object.remove("return_to");
|
|
388
|
+
}
|
|
389
|
+
attendee
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// Payment's own Client value object wants a single `name`, so a caller
|
|
393
|
+
// sending `first_name`/`last_name` instead gets a composed display name
|
|
394
|
+
// rather than this route hardcoding one shape.
|
|
395
|
+
fn display_name_from(body: &Value) -> Option<String> {
|
|
396
|
+
if let Some(name) = body.get("name").and_then(|v| v.as_str()) {
|
|
397
|
+
return Some(name.to_string());
|
|
398
|
+
}
|
|
399
|
+
let first = body.get("first_name").and_then(|v| v.as_str())?;
|
|
400
|
+
let last = body.get("last_name").and_then(|v| v.as_str())?;
|
|
401
|
+
Some(format!("{first} {last}"))
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// POST /registrations. Payment first, then Registration, sharing one
|
|
405
|
+
// reference minted here; `role: None` throughout since the site calls in
|
|
406
|
+
// server-to-server, not as a signed-in user.
|
|
407
|
+
#[allow(clippy::too_many_arguments)]
|
|
408
|
+
pub(crate) async fn registrations_route(
|
|
409
|
+
raw_body: &str,
|
|
410
|
+
platform: &payments::PlatformConfig,
|
|
411
|
+
client: &Mutex<Client>,
|
|
412
|
+
wasm_path: &Path,
|
|
413
|
+
config: &LineageConfig,
|
|
414
|
+
invoker: &dyn LambdaInvoker,
|
|
415
|
+
payments: &PaymentsProvider,
|
|
416
|
+
) -> Value {
|
|
417
|
+
let body: Value = match serde_json::from_str(raw_body) {
|
|
418
|
+
Ok(v) => v,
|
|
419
|
+
Err(e) => return respond(400, "application/json", &json!({"error": format!("invalid JSON: {e}")}).to_string()),
|
|
420
|
+
};
|
|
421
|
+
let Some(event_slug) = body.get("event_slug").and_then(|v| v.as_str()) else {
|
|
422
|
+
return respond(400, "application/json", &json!({"error": "missing event_slug"}).to_string());
|
|
423
|
+
};
|
|
424
|
+
// Cheap early exits before dispatching Payment.Initiate — full Attendee
|
|
425
|
+
// validation still happens downstream in Registration.Request's own
|
|
426
|
+
// given/invariant checks.
|
|
427
|
+
let Some(name) = display_name_from(&body) else {
|
|
428
|
+
return respond(400, "application/json", &json!({"error": "missing name (or first_name and last_name)"}).to_string());
|
|
429
|
+
};
|
|
430
|
+
let Some(email) = body.get("email").and_then(|v| v.as_str()) else {
|
|
431
|
+
return respond(400, "application/json", &json!({"error": "missing email"}).to_string());
|
|
432
|
+
};
|
|
433
|
+
|
|
434
|
+
let read = match dispatch::read(client, wasm_path).await {
|
|
435
|
+
Ok(r) => r,
|
|
436
|
+
Err(e) => return respond(500, "text/plain", &format!("{e:#}")),
|
|
437
|
+
};
|
|
438
|
+
let events = instances_for(&read, &crate::ir::registrations_binding(&config.domain).event_prefix());
|
|
439
|
+
let Some((_, event)) = events.iter().find(|(id, _)| id == event_slug) else {
|
|
440
|
+
return respond(404, "application/json", &json!({"error": "no such event"}).to_string());
|
|
441
|
+
};
|
|
442
|
+
if event.get("status").and_then(|v| v.as_str()) != Some("open") {
|
|
443
|
+
return respond(422, "application/json", &json!({"error": "registration is closed for this event"}).to_string());
|
|
444
|
+
}
|
|
445
|
+
// A full event refuses before any Stripe call or write. An event whose
|
|
446
|
+
// capacity cannot be read is not blocked: capacity is required when an
|
|
447
|
+
// event is scheduled, so its absence means nothing to enforce.
|
|
448
|
+
if seats_left(&read, &config.domain, payments, event_slug) == Some(0) {
|
|
449
|
+
return respond(409, "application/json", &json!({"error": "this event is full"}).to_string());
|
|
450
|
+
}
|
|
451
|
+
let price_cents = event.get("price").and_then(|p| p.get("cents")).and_then(|v| v.as_i64()).unwrap_or(0);
|
|
452
|
+
let event_name = event.get("name").and_then(|n| n.get("value")).and_then(|v| v.as_str()).unwrap_or("");
|
|
453
|
+
|
|
454
|
+
// What checkout does is decided per request from this tenant's own
|
|
455
|
+
// PaymentConnection, before anything is written — a paused connection
|
|
456
|
+
// must never fall back to the mock walkthrough.
|
|
457
|
+
let plan = payments::checkout_plan(payments::connection(&read, &config.domain).as_ref(), platform);
|
|
458
|
+
let processor = match plan {
|
|
459
|
+
payments::CheckoutPlan::Mock => "mock_stripe",
|
|
460
|
+
payments::CheckoutPlan::Stripe { .. } => "stripe",
|
|
461
|
+
payments::CheckoutPlan::Paused => {
|
|
462
|
+
return respond(503, "application/json", &json!({"error": "payments are temporarily unavailable"}).to_string());
|
|
463
|
+
}
|
|
464
|
+
};
|
|
465
|
+
let site_url = platform.site_url.as_str();
|
|
466
|
+
|
|
467
|
+
let reference = uuid::Uuid::new_v4().to_string();
|
|
468
|
+
|
|
469
|
+
// A Stripe plan opens its embedded session before anything is written, so
|
|
470
|
+
// a failure leaves no Payment or Registration behind. If a domain refusal
|
|
471
|
+
// follows, the unused session simply expires.
|
|
472
|
+
let embedded_checkout = if let payments::CheckoutPlan::Stripe { api_key, publishable_key } = &plan {
|
|
473
|
+
let auth = checkout::StripeAuth { api_key, base_url: &platform.api_base };
|
|
474
|
+
match checkout::create_checkout_session(&auth, price_cents, event_name, &reference, checkout::session_expires_at(unix_now())).await {
|
|
475
|
+
// Stripe.js is opened with the publishable key; the answer carries
|
|
476
|
+
// no `checkout_url`.
|
|
477
|
+
Ok(session) => Some(json!({
|
|
478
|
+
"client_secret": session.client_secret,
|
|
479
|
+
"publishable_key": publishable_key,
|
|
480
|
+
"session_id": session.session_id,
|
|
481
|
+
})),
|
|
482
|
+
Err(e) => {
|
|
483
|
+
eprintln!("registrations: opening the Stripe session failed, nothing was recorded: {e:#}");
|
|
484
|
+
return respond(502, "application/json", &json!({"error": "payments are temporarily unavailable"}).to_string());
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
} else {
|
|
488
|
+
None
|
|
489
|
+
};
|
|
490
|
+
|
|
491
|
+
let initiate_args = json!({
|
|
492
|
+
"reference": {"value": reference},
|
|
493
|
+
"processor": {"value": processor},
|
|
494
|
+
"payment_type": {"value": "card"},
|
|
495
|
+
"amount": {"cents": price_cents},
|
|
496
|
+
"client": {"name": name, "email": email},
|
|
497
|
+
});
|
|
498
|
+
let outcome = match dispatch::handle(client, wasm_path, &payments.initiate, initiate_args, None, config, invoker).await {
|
|
499
|
+
Ok(o) => o,
|
|
500
|
+
Err(e) => return respond(500, "text/plain", &format!("{e:#}")),
|
|
501
|
+
};
|
|
502
|
+
if !outcome.accepted {
|
|
503
|
+
return respond(422, "application/json", &last_refusal(&outcome.result).to_string());
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
let mut request_args = json!({
|
|
507
|
+
"event_slug": event_slug,
|
|
508
|
+
"registration_id": {"value": reference},
|
|
509
|
+
"attendee": attendee_from(&body),
|
|
510
|
+
});
|
|
511
|
+
// A reaction reads only fields its event carries, never ones nested
|
|
512
|
+
// inside `attendee`, so newsletter fields are declared flat on
|
|
513
|
+
// Registration.Request; a command refuses args it doesn't declare.
|
|
514
|
+
let news_signup = body.get("news_signup").and_then(|v| v.as_bool()).unwrap_or(false);
|
|
515
|
+
let binding = crate::ir::registrations_binding(&config.domain);
|
|
516
|
+
let forwards_newsletter = crate::ir::ir().is_some_and(|ir| {
|
|
517
|
+
binding.request_target().is_some_and(|(aggregate, command)| crate::ir::command_declares(ir, aggregate, command, "news_signup"))
|
|
518
|
+
});
|
|
519
|
+
if forwards_newsletter {
|
|
520
|
+
request_args["news_signup"] = json!(news_signup);
|
|
521
|
+
request_args["email"] = json!(email);
|
|
522
|
+
for field in ["first_name", "last_name"] {
|
|
523
|
+
if let Some(value) = body.get(field) {
|
|
524
|
+
request_args[field] = value.clone();
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
let request_verb = crate::ir::registrations_binding(&config.domain).request;
|
|
529
|
+
let outcome = match dispatch::handle(client, wasm_path, &request_verb, request_args, None, config, invoker).await {
|
|
530
|
+
Ok(o) => o,
|
|
531
|
+
Err(e) => return respond(500, "text/plain", &format!("{e:#}")),
|
|
532
|
+
};
|
|
533
|
+
if !outcome.accepted {
|
|
534
|
+
return respond(422, "application/json", &last_refusal(&outcome.result).to_string());
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// The domain's reaction has already subscribed the address by now; a
|
|
538
|
+
// registrant who ticked the box gets the same signed confirm link as the
|
|
539
|
+
// footer form. Best effort: the registration stands whatever mail does.
|
|
540
|
+
if forwards_newsletter && news_signup {
|
|
541
|
+
super::newsletter::send_confirmation_if_pending(email, client, wasm_path).await;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
if let Some(embedded) = embedded_checkout {
|
|
545
|
+
return respond(200, "application/json", &json!({"registration_id": reference, "embedded_checkout": embedded}).to_string());
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// `return_to` is guest-supplied and never trusted raw — only a same-site
|
|
549
|
+
// path (one leading slash, not two) is kept, since e.g. "//evil.example"
|
|
550
|
+
// would otherwise be an open redirect; anything else falls back to "/".
|
|
551
|
+
let return_to = match body.get("return_to").and_then(|v| v.as_str()) {
|
|
552
|
+
Some(path) if path.starts_with('/') && !path.starts_with("//") => path.to_string(),
|
|
553
|
+
_ => "/".to_string(),
|
|
554
|
+
};
|
|
555
|
+
let confirm_query = |outcome: &str| -> String {
|
|
556
|
+
let mut url = reqwest::Url::parse("http://placeholder.invalid/").unwrap();
|
|
557
|
+
url.query_pairs_mut()
|
|
558
|
+
.append_pair("registration_id", &reference)
|
|
559
|
+
.append_pair("outcome", outcome)
|
|
560
|
+
.append_pair("return_to", &return_to);
|
|
561
|
+
url.query().unwrap_or("").to_string()
|
|
562
|
+
};
|
|
563
|
+
let success_url = format!("{site_url}/registration-confirmed.html?{}", confirm_query("succeeded"));
|
|
564
|
+
let cancel_url = format!("{site_url}/registration-confirmed.html?{}", confirm_query("cancelled"));
|
|
565
|
+
|
|
566
|
+
// A tenant with no connection, or a disabled one, is on the mock
|
|
567
|
+
// walkthrough, never a misconfiguration to refuse. success_url/cancel_url
|
|
568
|
+
// belong to this walkthrough only — an embedded session redirects nowhere.
|
|
569
|
+
let checkout_url = checkout::mock_checkout_session(&reference, &success_url, &cancel_url, site_url);
|
|
570
|
+
respond(200, "application/json", &json!({"checkout_url": checkout_url, "registration_id": reference}).to_string())
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// The answer for an event that only the public mock secret could vouch for.
|
|
574
|
+
fn mock_secret_refused() -> Value {
|
|
575
|
+
respond(
|
|
576
|
+
500,
|
|
577
|
+
"application/json",
|
|
578
|
+
&json!({"error": "STRIPE_WEBHOOK_SECRET is required to accept events from a real payment processor -- \
|
|
579
|
+
refusing to trust the publicly-known mock webhook secret"})
|
|
580
|
+
.to_string(),
|
|
581
|
+
)
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/// POST /webhooks/stripe. Verifies the signature against
|
|
585
|
+
/// `STRIPE_WEBHOOK_SECRET`, a saved signing secret, or — only while neither
|
|
586
|
+
/// is set — the public mock secret, refusing anything only a real processor
|
|
587
|
+
/// could have sent.
|
|
588
|
+
pub(crate) async fn webhook_route(
|
|
589
|
+
raw_body: &str,
|
|
590
|
+
signature_header: &str,
|
|
591
|
+
platform: &payments::PlatformConfig,
|
|
592
|
+
client: &Mutex<Client>,
|
|
593
|
+
wasm_path: &Path,
|
|
594
|
+
config: &LineageConfig,
|
|
595
|
+
invoker: &dyn LambdaInvoker,
|
|
596
|
+
payments: &PaymentsProvider,
|
|
597
|
+
) -> Value {
|
|
598
|
+
let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs() as i64;
|
|
599
|
+
// The environment's signing secret and any saved with the business's own
|
|
600
|
+
// keys are all accepted; with none configured only the mock secret is.
|
|
601
|
+
let configured = platform.webhook_secrets();
|
|
602
|
+
if configured.is_empty() && platform.has_real_credentials() {
|
|
603
|
+
// A real key with no signing secret to check events against: the public
|
|
604
|
+
// mock secret must not stand in for it.
|
|
605
|
+
return mock_secret_refused();
|
|
606
|
+
}
|
|
607
|
+
let candidates = if configured.is_empty() { vec![MOCK_STRIPE_WEBHOOK_SECRET] } else { configured };
|
|
608
|
+
let mut verification = Ok(());
|
|
609
|
+
for secret in &candidates {
|
|
610
|
+
verification = checkout::verify_signature(raw_body, signature_header, secret, now);
|
|
611
|
+
if verification.is_ok() {
|
|
612
|
+
break;
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
if let Err(e) = verification {
|
|
616
|
+
return respond(400, "text/plain", &e.to_string());
|
|
617
|
+
}
|
|
618
|
+
let event: Value = match serde_json::from_str(raw_body) {
|
|
619
|
+
Ok(v) => v,
|
|
620
|
+
Err(_) => return respond(400, "text/plain", "invalid JSON"),
|
|
621
|
+
};
|
|
622
|
+
|
|
623
|
+
let read = match dispatch::read(client, wasm_path).await {
|
|
624
|
+
Ok(r) => r,
|
|
625
|
+
Err(e) => return respond(500, "text/plain", &format!("{e:#}")),
|
|
626
|
+
};
|
|
627
|
+
// The business's own endpoint delivers events with no `account`; one that
|
|
628
|
+
// names an account is some other account's, acknowledged and ignored.
|
|
629
|
+
if event.get("account").and_then(|v| v.as_str()).is_some() {
|
|
630
|
+
return respond(200, "text/plain", "");
|
|
631
|
+
}
|
|
632
|
+
let fallback_secret = platform.webhook_secrets().is_empty();
|
|
633
|
+
let refuse_fallback = mock_secret_refused;
|
|
634
|
+
|
|
635
|
+
let event_type = event.get("type").and_then(|v| v.as_str()).unwrap_or("");
|
|
636
|
+
let object = event.get("data").and_then(|d| d.get("object")).cloned().unwrap_or_else(|| json!({}));
|
|
637
|
+
let reference = object.get("metadata").and_then(|m| m.get("registration_id")).and_then(|v| v.as_str()).map(String::from);
|
|
638
|
+
|
|
639
|
+
if let Some(reference) = reference {
|
|
640
|
+
// Defaults to "stripe" when there's no matching Payment.
|
|
641
|
+
let processor = payment_processor(&read, payments, &reference).unwrap_or_else(|| "stripe".to_string());
|
|
642
|
+
if fallback_secret && processor != "mock_stripe" {
|
|
643
|
+
return refuse_fallback();
|
|
644
|
+
}
|
|
645
|
+
let reported_processor = json!({"value": processor});
|
|
646
|
+
// The kernel refuses a flat `{"reference": ...}` for an
|
|
647
|
+
// aggregate-scoped port operation — `to` is the bare reference
|
|
648
|
+
// string, and `reference` stays inside `with:` too, since the
|
|
649
|
+
// argument parser still expects to find it in facts.
|
|
650
|
+
let reference_fact = json!({"value": reference.clone()});
|
|
651
|
+
let verb_and_facts = match event_type {
|
|
652
|
+
"checkout.session.completed" => {
|
|
653
|
+
// The PaymentIntent id when one exists, else the Checkout
|
|
654
|
+
// Session's own id — `.get`, not a panic, since a synthetic
|
|
655
|
+
// test payload carries no payment_intent at all.
|
|
656
|
+
let transaction_id = object.get("payment_intent").and_then(|v| v.as_str())
|
|
657
|
+
.or_else(|| object.get("id").and_then(|v| v.as_str()))
|
|
658
|
+
.unwrap_or("")
|
|
659
|
+
.to_string();
|
|
660
|
+
Some((payments.succeeded.as_str(), json!({
|
|
661
|
+
"reference": reference_fact,
|
|
662
|
+
"transaction_id": {"value": transaction_id},
|
|
663
|
+
"reported_processor": reported_processor,
|
|
664
|
+
})))
|
|
665
|
+
}
|
|
666
|
+
"checkout.session.expired" => Some((payments.failed.as_str(), json!({
|
|
667
|
+
"reference": reference_fact,
|
|
668
|
+
"reason": {"value": "checkout_expired"},
|
|
669
|
+
"reported_processor": reported_processor,
|
|
670
|
+
}))),
|
|
671
|
+
_ => None,
|
|
672
|
+
};
|
|
673
|
+
|
|
674
|
+
if let Some((verb, facts)) = verb_and_facts {
|
|
675
|
+
// A refusal here (e.g. a redelivered webhook — Stripe's delivery
|
|
676
|
+
// is at-least-once) is a benign no-op: the payment already holds
|
|
677
|
+
// the right status, and 200 tells Stripe's retry logic to stop.
|
|
678
|
+
if let Err(e) = dispatch::handle_routed(client, wasm_path, verb, json!(reference), facts, None, config, invoker).await {
|
|
679
|
+
return respond(500, "text/plain", &format!("{e:#}"));
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
respond(200, "text/plain", "")
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
#[cfg(test)]
|
|
688
|
+
mod tests {
|
|
689
|
+
use super::*;
|
|
690
|
+
use crate::lambda_client;
|
|
691
|
+
use crate::web::tests::{provision_lineage, scratch_db};
|
|
692
|
+
|
|
693
|
+
fn checkout_config(era: i32) -> LineageConfig {
|
|
694
|
+
LineageConfig { domain: "CheckoutFixture".to_string(), era: Some(era), mirrored: None }
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
fn checkout_wasm_path() -> std::path::PathBuf {
|
|
698
|
+
std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../dist/checkout_fixture.wasm")
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
async fn schedule_event(client: &Mutex<Client>, wasm_path: &Path, config: &LineageConfig, slug: &str, price_cents: i64) {
|
|
702
|
+
let args = json!({
|
|
703
|
+
"slug": {"value": slug}, "name": {"value": "Sample Studio"},
|
|
704
|
+
"price": {"cents": price_cents}, "capacity": {"value": 20},
|
|
705
|
+
});
|
|
706
|
+
let outcome = dispatch::handle(client, wasm_path, "CheckoutFixture::Event.Schedule", args, None, config, &lambda_client::NeverInvoker)
|
|
707
|
+
.await
|
|
708
|
+
.unwrap();
|
|
709
|
+
assert!(outcome.accepted, "scheduling the fixture event should succeed: {:?}", outcome.result);
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
fn sign_stripe_header(secret: &str, now: i64, payload: &str) -> String {
|
|
713
|
+
use hmac::{Hmac, Mac};
|
|
714
|
+
use sha2::Sha256;
|
|
715
|
+
let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).unwrap();
|
|
716
|
+
mac.update(format!("{now}.{payload}").as_bytes());
|
|
717
|
+
format!("t={now},v1={}", mac.finalize().into_bytes().iter().map(|b| format!("{b:02x}")).collect::<String>())
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
fn now_secs() -> i64 {
|
|
721
|
+
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() as i64
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
// A read of the domain: one `yoga` event of `capacity` seats, one `other`
|
|
725
|
+
// event, and a Registration plus same-reference Payment per row, each as
|
|
726
|
+
// (reference, event slug, payment status).
|
|
727
|
+
fn read_with(capacity: i64, rows: &[(&str, &str, &str)]) -> Value {
|
|
728
|
+
let mut instances = serde_json::Map::new();
|
|
729
|
+
instances.insert("CheckoutFixture::Event#yoga".into(), json!({"capacity": {"value": capacity}}));
|
|
730
|
+
instances.insert("CheckoutFixture::Event#other".into(), json!({"capacity": {"value": 5}}));
|
|
731
|
+
for (reference, event, status) in rows {
|
|
732
|
+
instances.insert(format!("CheckoutFixture::Registration#{reference}"), json!({"event_slug": event}));
|
|
733
|
+
instances.insert(format!("Payments::Payment#{reference}"), json!({"status": status}));
|
|
734
|
+
}
|
|
735
|
+
json!({"instances": instances})
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
#[test]
|
|
739
|
+
fn a_registration_holds_a_seat_while_its_payment_is_open_paid_or_being_settled() {
|
|
740
|
+
let payments = crate::ir::fixture_payments();
|
|
741
|
+
let read = read_with(
|
|
742
|
+
10,
|
|
743
|
+
&[("a", "yoga", "pending"), ("b", "yoga", "succeeded"), ("c", "yoga", "refunding"), ("d", "yoga", "disputed")],
|
|
744
|
+
);
|
|
745
|
+
assert_eq!(seats_taken(&read, "CheckoutFixture", &payments, "yoga"), 4);
|
|
746
|
+
assert_eq!(seats_left(&read, "CheckoutFixture", &payments, "yoga"), Some(6));
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
#[test]
|
|
750
|
+
fn a_failed_refunded_or_charged_back_payment_gives_its_seat_back() {
|
|
751
|
+
let payments = crate::ir::fixture_payments();
|
|
752
|
+
let read = read_with(
|
|
753
|
+
3,
|
|
754
|
+
&[("a", "yoga", "failed"), ("b", "yoga", "refunded"), ("c", "yoga", "charged_back"), ("d", "yoga", "succeeded")],
|
|
755
|
+
);
|
|
756
|
+
assert_eq!(seats_taken(&read, "CheckoutFixture", &payments, "yoga"), 1);
|
|
757
|
+
assert_eq!(seats_left(&read, "CheckoutFixture", &payments, "yoga"), Some(2));
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
#[test]
|
|
761
|
+
fn seats_are_counted_per_event_and_a_registration_without_a_payment_holds_none() {
|
|
762
|
+
let payments = crate::ir::fixture_payments();
|
|
763
|
+
let mut read = read_with(4, &[("a", "yoga", "succeeded"), ("b", "other", "succeeded")]);
|
|
764
|
+
read["instances"]["CheckoutFixture::Registration#orphan"] = json!({"event_slug": "yoga"});
|
|
765
|
+
assert_eq!(seats_taken(&read, "CheckoutFixture", &payments, "yoga"), 1, "only this event's registrations, and only those with a payment");
|
|
766
|
+
assert_eq!(seats_taken(&read, "CheckoutFixture", &payments, "other"), 1);
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
#[test]
|
|
770
|
+
fn an_archived_registration_gives_its_seat_back_whatever_its_payment_says() {
|
|
771
|
+
let payments = crate::ir::fixture_payments();
|
|
772
|
+
let mut read = read_with(3, &[("a", "yoga", "succeeded"), ("b", "yoga", "pending"), ("c", "yoga", "succeeded")]);
|
|
773
|
+
assert_eq!(seats_taken(&read, "CheckoutFixture", &payments, "yoga"), 3);
|
|
774
|
+
read["instances"]["CheckoutFixture::Registration#a"]["status"] = json!("archived");
|
|
775
|
+
assert_eq!(seats_taken(&read, "CheckoutFixture", &payments, "yoga"), 2, "a paid but archived registration frees its seat");
|
|
776
|
+
assert_eq!(seats_left(&read, "CheckoutFixture", &payments, "yoga"), Some(1));
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
#[test]
|
|
780
|
+
fn a_registration_with_no_status_counts_as_active() {
|
|
781
|
+
let payments = crate::ir::fixture_payments();
|
|
782
|
+
let mut read = read_with(5, &[("a", "yoga", "succeeded"), ("b", "yoga", "succeeded")]);
|
|
783
|
+
// `a` carries no status at all (a domain without the lifecycle, or data
|
|
784
|
+
// from before it existed); `b` says it is active.
|
|
785
|
+
read["instances"]["CheckoutFixture::Registration#b"]["status"] = json!("active");
|
|
786
|
+
assert_eq!(seats_taken(&read, "CheckoutFixture", &payments, "yoga"), 2);
|
|
787
|
+
read["instances"]["CheckoutFixture::Registration#a"]["status"] = json!(null);
|
|
788
|
+
assert_eq!(seats_taken(&read, "CheckoutFixture", &payments, "yoga"), 2, "a null status is not archived either");
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
#[test]
|
|
792
|
+
fn a_restored_registration_takes_its_seat_back() {
|
|
793
|
+
let payments = crate::ir::fixture_payments();
|
|
794
|
+
let mut read = read_with(2, &[("a", "yoga", "succeeded")]);
|
|
795
|
+
read["instances"]["CheckoutFixture::Registration#a"]["status"] = json!("archived");
|
|
796
|
+
assert_eq!(seats_left(&read, "CheckoutFixture", &payments, "yoga"), Some(2));
|
|
797
|
+
read["instances"]["CheckoutFixture::Registration#a"]["status"] = json!("active");
|
|
798
|
+
assert_eq!(seats_left(&read, "CheckoutFixture", &payments, "yoga"), Some(1));
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
#[test]
|
|
802
|
+
fn seats_left_with_mixed_statuses_counts_only_the_active_holders() {
|
|
803
|
+
let payments = crate::ir::fixture_payments();
|
|
804
|
+
let mut read = read_with(
|
|
805
|
+
6,
|
|
806
|
+
&[("a", "yoga", "succeeded"), ("b", "yoga", "succeeded"), ("c", "yoga", "pending"), ("d", "yoga", "failed"), ("e", "other", "succeeded")],
|
|
807
|
+
);
|
|
808
|
+
read["instances"]["CheckoutFixture::Registration#a"]["status"] = json!("archived");
|
|
809
|
+
read["instances"]["CheckoutFixture::Registration#b"]["status"] = json!("active");
|
|
810
|
+
read["instances"]["CheckoutFixture::Registration#d"]["status"] = json!("active");
|
|
811
|
+
read["instances"]["CheckoutFixture::Registration#e"]["status"] = json!("archived");
|
|
812
|
+
// b (active, paid) and c (no status, pending) hold; a is archived, d's
|
|
813
|
+
// payment failed, and e is another event's.
|
|
814
|
+
assert_eq!(seats_taken(&read, "CheckoutFixture", &payments, "yoga"), 2);
|
|
815
|
+
assert_eq!(seats_left(&read, "CheckoutFixture", &payments, "yoga"), Some(4));
|
|
816
|
+
assert_eq!(seats_left(&read, "CheckoutFixture", &payments, "other"), Some(5), "the archived registration on `other` frees its seat");
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
#[test]
|
|
820
|
+
fn seats_left_never_goes_below_zero_and_is_none_without_an_event_or_capacity() {
|
|
821
|
+
let payments = crate::ir::fixture_payments();
|
|
822
|
+
let oversold = read_with(1, &[("a", "yoga", "succeeded"), ("b", "yoga", "succeeded")]);
|
|
823
|
+
assert_eq!(seats_left(&oversold, "CheckoutFixture", &payments, "yoga"), Some(0));
|
|
824
|
+
assert_eq!(seats_left(&oversold, "CheckoutFixture", &payments, "nowhere"), None);
|
|
825
|
+
let no_capacity = json!({"instances": {"CheckoutFixture::Event#yoga": {"name": {"value": "Yoga"}}}});
|
|
826
|
+
assert_eq!(seats_left(&no_capacity, "CheckoutFixture", &payments, "yoga"), None);
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
// The site's own JavaScript tests of this rule (its seat-count spec) use the
|
|
830
|
+
// fixtures below; the same cases run here so the two counts cannot drift.
|
|
831
|
+
const HOLDING: [&str; 4] = ["pending", "succeeded", "refunding", "disputed"];
|
|
832
|
+
const FREEING: [&str; 3] = ["failed", "refunded", "charged_back"];
|
|
833
|
+
|
|
834
|
+
fn set_registration_status(read: &mut Value, reference: &str, status: &str) {
|
|
835
|
+
read["instances"][format!("CheckoutFixture::Registration#{reference}")]["status"] = json!(status);
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
#[test]
|
|
839
|
+
fn the_seat_holding_table_is_exactly_pending_succeeded_refunding_and_disputed() {
|
|
840
|
+
let mut table = SEAT_HOLDING_PAYMENT_STATUSES;
|
|
841
|
+
table.sort_unstable();
|
|
842
|
+
let mut expected = HOLDING;
|
|
843
|
+
expected.sort_unstable();
|
|
844
|
+
assert_eq!(table, expected);
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
#[test]
|
|
848
|
+
fn every_holding_status_holds_one_seat_and_every_freeing_status_holds_none() {
|
|
849
|
+
let payments = crate::ir::fixture_payments();
|
|
850
|
+
for status in HOLDING {
|
|
851
|
+
let read = read_with(10, &[("r", "yoga", status)]);
|
|
852
|
+
assert_eq!(seats_taken(&read, "CheckoutFixture", &payments, "yoga"), 1, "{status} should hold a seat");
|
|
853
|
+
}
|
|
854
|
+
for status in FREEING {
|
|
855
|
+
let read = read_with(10, &[("r", "yoga", status)]);
|
|
856
|
+
assert_eq!(seats_taken(&read, "CheckoutFixture", &payments, "yoga"), 0, "{status} should free the seat");
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
#[test]
|
|
861
|
+
fn a_reference_reads_as_a_plain_id_or_a_wrapped_value_and_counts_per_event() {
|
|
862
|
+
let payments = crate::ir::fixture_payments();
|
|
863
|
+
let mut read = read_with(
|
|
864
|
+
10,
|
|
865
|
+
&[("a", "yoga", "succeeded"), ("b", "yoga", "pending"), ("c", "other", "pending"), ("d", "yoga", "failed")],
|
|
866
|
+
);
|
|
867
|
+
read["instances"]["CheckoutFixture::Registration#b"]["event_slug"] = json!({"value": "yoga"});
|
|
868
|
+
assert_eq!(seats_taken(&read, "CheckoutFixture", &payments, "yoga"), 2);
|
|
869
|
+
assert_eq!(seats_taken(&read, "CheckoutFixture", &payments, "other"), 1);
|
|
870
|
+
read["instances"]["CheckoutFixture::Registration#a"]["event_slug"] = json!("");
|
|
871
|
+
assert_eq!(seats_taken(&read, "CheckoutFixture", &payments, "yoga"), 1, "an empty event id is no reference");
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
#[test]
|
|
875
|
+
fn a_payment_with_no_registration_is_ignored_and_a_missing_or_empty_read_counts_nothing() {
|
|
876
|
+
let payments = crate::ir::fixture_payments();
|
|
877
|
+
let mut read = read_with(5, &[]);
|
|
878
|
+
read["instances"]["CheckoutFixture::Registration#lonely"] = json!({"event_slug": "yoga"});
|
|
879
|
+
read["instances"]["Payments::Payment#orphan"] = json!({"status": "succeeded"});
|
|
880
|
+
assert_eq!(seats_taken(&read, "CheckoutFixture", &payments, "yoga"), 0);
|
|
881
|
+
for empty in [json!({}), json!({"instances": {}}), Value::Null] {
|
|
882
|
+
assert_eq!(seats_taken(&empty, "CheckoutFixture", &payments, "yoga"), 0);
|
|
883
|
+
assert!(seat_counts(&empty, "CheckoutFixture", &payments).is_empty());
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
#[test]
|
|
888
|
+
fn an_archived_registration_frees_its_seat_whichever_holding_status_its_payment_has() {
|
|
889
|
+
let payments = crate::ir::fixture_payments();
|
|
890
|
+
for status in HOLDING {
|
|
891
|
+
let mut read = read_with(10, &[("r", "yoga", status)]);
|
|
892
|
+
set_registration_status(&mut read, "r", "archived");
|
|
893
|
+
assert_eq!(seats_taken(&read, "CheckoutFixture", &payments, "yoga"), 0, "archived + {status} should not hold a seat");
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
#[test]
|
|
898
|
+
fn archiving_one_of_several_guests_frees_exactly_one_seat() {
|
|
899
|
+
let payments = crate::ir::fixture_payments();
|
|
900
|
+
let mut read = read_with(10, &[("a", "yoga", "succeeded"), ("b", "yoga", "succeeded"), ("c", "yoga", "pending")]);
|
|
901
|
+
set_registration_status(&mut read, "a", "active");
|
|
902
|
+
set_registration_status(&mut read, "b", "archived");
|
|
903
|
+
set_registration_status(&mut read, "c", "active");
|
|
904
|
+
assert_eq!(seats_taken(&read, "CheckoutFixture", &payments, "yoga"), 2);
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
#[test]
|
|
908
|
+
fn seat_figures_carry_capacity_taken_and_left_and_null_without_a_capacity() {
|
|
909
|
+
let payments = crate::ir::fixture_payments();
|
|
910
|
+
let read = read_with(3, &[("a", "yoga", "succeeded"), ("b", "yoga", "pending"), ("c", "yoga", "succeeded"), ("d", "yoga", "succeeded")]);
|
|
911
|
+
assert_eq!(one_seat_figures(&read, "CheckoutFixture", &payments, "yoga"), Some(json!({"slug": "yoga", "capacity": 3, "seats_taken": 4, "seats_left": 0})));
|
|
912
|
+
assert_eq!(one_seat_figures(&read, "CheckoutFixture", &payments, "nowhere"), None);
|
|
913
|
+
assert_eq!(
|
|
914
|
+
all_seat_figures(&read, "CheckoutFixture", &payments),
|
|
915
|
+
json!({"events": {
|
|
916
|
+
"yoga": {"capacity": 3, "seats_taken": 4, "seats_left": 0},
|
|
917
|
+
"other": {"capacity": 5, "seats_taken": 0, "seats_left": 5},
|
|
918
|
+
}})
|
|
919
|
+
);
|
|
920
|
+
let no_capacity = json!({"instances": {"CheckoutFixture::Event#yoga": {"name": {"value": "Yoga"}}}});
|
|
921
|
+
assert_eq!(
|
|
922
|
+
one_seat_figures(&no_capacity, "CheckoutFixture", &payments, "yoga"),
|
|
923
|
+
Some(json!({"slug": "yoga", "capacity": null, "seats_taken": 0, "seats_left": null}))
|
|
924
|
+
);
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
#[test]
|
|
928
|
+
fn the_seat_figures_agree_with_seats_left_for_every_event() {
|
|
929
|
+
let payments = crate::ir::fixture_payments();
|
|
930
|
+
let mut read = read_with(6, &[("a", "yoga", "succeeded"), ("b", "yoga", "refunding"), ("c", "other", "pending"), ("d", "yoga", "failed")]);
|
|
931
|
+
set_registration_status(&mut read, "b", "archived");
|
|
932
|
+
let all = all_seat_figures(&read, "CheckoutFixture", &payments);
|
|
933
|
+
for slug in ["yoga", "other"] {
|
|
934
|
+
assert_eq!(all["events"][slug]["seats_left"].as_i64(), seats_left(&read, "CheckoutFixture", &payments, slug), "{slug}");
|
|
935
|
+
assert_eq!(all["events"][slug]["seats_taken"].as_u64(), Some(seats_taken(&read, "CheckoutFixture", &payments, slug) as u64), "{slug}");
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
#[test]
|
|
940
|
+
fn only_a_single_segment_slug_between_events_and_seats_names_an_event() {
|
|
941
|
+
assert_eq!(seats_path_slug("/events/yoga/seats"), Some("yoga"));
|
|
942
|
+
for path in ["/events/seats", "/events//seats", "/events/a/b/seats", "/events/yoga", "/events/yoga/seats/x", "/registrations/x/seats"] {
|
|
943
|
+
assert_eq!(seats_path_slug(path), None, "{path}");
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
#[tokio::test]
|
|
948
|
+
async fn the_seat_routes_answer_from_a_real_read_and_404_an_unknown_event() {
|
|
949
|
+
let client = scratch_db("hecks_host_web_test_seat_routes").await;
|
|
950
|
+
provision_lineage(&*client.lock().await, "CheckoutFixture", 1, &["Event", "Registration", "Payment"]).await;
|
|
951
|
+
let config = checkout_config(1);
|
|
952
|
+
let wasm_path = checkout_wasm_path();
|
|
953
|
+
schedule_event(&client, &wasm_path, &config, "yoga", 4200).await;
|
|
954
|
+
let ir = crate::ir::fixture_ir();
|
|
955
|
+
let get = |path: &'static str| {
|
|
956
|
+
let (client, wasm_path, config, ir) = (&client, &wasm_path, &config, &ir);
|
|
957
|
+
async move {
|
|
958
|
+
let response = payments_routes(Some(ir), "GET", path, "", "", client, wasm_path, config, &lambda_client::NeverInvoker).await.expect("a seats path");
|
|
959
|
+
(response["statusCode"].as_u64().unwrap(), serde_json::from_str::<Value>(response["body"].as_str().unwrap()).unwrap())
|
|
960
|
+
}
|
|
961
|
+
};
|
|
962
|
+
assert_eq!(get("/events/yoga/seats").await, (200, json!({"slug": "yoga", "capacity": 20, "seats_taken": 0, "seats_left": 20})));
|
|
963
|
+
assert_eq!(get("/events/seats").await, (200, json!({"events": {"yoga": {"capacity": 20, "seats_taken": 0, "seats_left": 20}}})));
|
|
964
|
+
assert_eq!(get("/events/nowhere/seats").await, (404, json!({"error": "no such event"})));
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
#[test]
|
|
968
|
+
fn checkout_is_enabled_only_for_the_exactly_configured_domain() {
|
|
969
|
+
assert!(checkout_enabled(Some("CheckoutFixture"), "CheckoutFixture"));
|
|
970
|
+
assert!(!checkout_enabled(None, "CheckoutFixture"));
|
|
971
|
+
assert!(!checkout_enabled(Some(""), "CheckoutFixture"));
|
|
972
|
+
assert!(!checkout_enabled(Some("Banking"), "CheckoutFixture"));
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
#[test]
|
|
976
|
+
fn attendee_from_strips_only_routing_metadata_not_attendee_fields() {
|
|
977
|
+
let body = json!({
|
|
978
|
+
"event_slug": "yoga-aug",
|
|
979
|
+
"return_to": "/yoga-aug",
|
|
980
|
+
"first_name": "Ada",
|
|
981
|
+
"last_name": "Lovelace",
|
|
982
|
+
"email": "ada@example.com",
|
|
983
|
+
"phone": "555-0100",
|
|
984
|
+
"previous_sessions": false,
|
|
985
|
+
"first_time": true,
|
|
986
|
+
"how_heard": "a friend",
|
|
987
|
+
"aim": "flexibility",
|
|
988
|
+
});
|
|
989
|
+
|
|
990
|
+
let attendee = attendee_from(&body);
|
|
991
|
+
|
|
992
|
+
assert_eq!(attendee.get("event_slug"), None, "routing metadata must not leak into Attendee");
|
|
993
|
+
assert_eq!(attendee.get("return_to"), None, "routing metadata must not leak into Attendee");
|
|
994
|
+
assert_eq!(attendee["first_name"], "Ada");
|
|
995
|
+
assert_eq!(attendee["last_name"], "Lovelace");
|
|
996
|
+
assert_eq!(attendee["email"], "ada@example.com");
|
|
997
|
+
assert_eq!(attendee["phone"], "555-0100");
|
|
998
|
+
assert_eq!(attendee["previous_sessions"], false);
|
|
999
|
+
assert_eq!(attendee["first_time"], true);
|
|
1000
|
+
assert_eq!(attendee["how_heard"], "a friend");
|
|
1001
|
+
assert_eq!(attendee["aim"], "flexibility");
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
#[test]
|
|
1005
|
+
fn attendee_from_forwards_the_old_flat_shape_unchanged_for_backward_compatibility() {
|
|
1006
|
+
// Must round-trip byte-for-byte — this exact shape is what
|
|
1007
|
+
// CheckoutFixture's tests, and any simple-Attendee domain, send.
|
|
1008
|
+
let body = json!({"event_slug": "happy-event", "name": "Ada Lovelace", "email": "ada@example.com"});
|
|
1009
|
+
assert_eq!(attendee_from(&body), json!({"name": "Ada Lovelace", "email": "ada@example.com"}));
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
#[test]
|
|
1013
|
+
fn display_name_from_prefers_a_flat_name_field_when_present() {
|
|
1014
|
+
let body = json!({"name": "Ada Lovelace", "first_name": "should be ignored", "last_name": "should be ignored"});
|
|
1015
|
+
assert_eq!(display_name_from(&body), Some("Ada Lovelace".to_string()));
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
#[test]
|
|
1019
|
+
fn display_name_from_composes_first_and_last_name_when_no_flat_name_exists() {
|
|
1020
|
+
let body = json!({"first_name": "Ada", "last_name": "Lovelace"});
|
|
1021
|
+
assert_eq!(display_name_from(&body), Some("Ada Lovelace".to_string()));
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
#[test]
|
|
1025
|
+
fn display_name_from_is_none_when_neither_shape_is_present() {
|
|
1026
|
+
assert_eq!(display_name_from(&json!({"email": "ada@example.com"})), None);
|
|
1027
|
+
assert_eq!(display_name_from(&json!({"first_name": "Ada"})), None, "last_name alone is missing");
|
|
1028
|
+
assert_eq!(display_name_from(&json!({"last_name": "Lovelace"})), None, "first_name alone is missing");
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
#[tokio::test]
|
|
1032
|
+
async fn registrations_route_refuses_a_body_missing_any_required_field() {
|
|
1033
|
+
let client = scratch_db("hecks_host_web_test_registrations_missing_fields").await;
|
|
1034
|
+
provision_lineage(&*client.lock().await, "CheckoutFixture", 1, &["Event", "Registration", "Payment"]).await;
|
|
1035
|
+
|
|
1036
|
+
let response = registrations_route(r#"{"event_slug":"yoga-aug"}"#, &payments::test_platform(), &client, &checkout_wasm_path(), &checkout_config(1), &lambda_client::NeverInvoker, &crate::ir::fixture_payments()).await;
|
|
1037
|
+
assert_eq!(response["statusCode"], 400);
|
|
1038
|
+
assert!(response["body"].as_str().unwrap().contains("missing name"));
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
// A caller submits `first_name`/`last_name` against CheckoutFixture's
|
|
1042
|
+
// `name`-only Attendee — proves display_name_from composes a real Client
|
|
1043
|
+
// name and attendee_from forwards fields verbatim, without a second fixture.
|
|
1044
|
+
#[tokio::test]
|
|
1045
|
+
async fn registrations_route_forwards_a_new_shaped_attendee_verbatim_even_against_an_old_shaped_fixture() {
|
|
1046
|
+
let client = scratch_db("hecks_host_web_test_registrations_new_shape").await;
|
|
1047
|
+
provision_lineage(&*client.lock().await, "CheckoutFixture", 1, &["Event", "Registration", "Payment"]).await;
|
|
1048
|
+
let config = checkout_config(1);
|
|
1049
|
+
let wasm_path = checkout_wasm_path();
|
|
1050
|
+
|
|
1051
|
+
schedule_event(&client, &wasm_path, &config, "happy-event", 4200).await;
|
|
1052
|
+
|
|
1053
|
+
let body = json!({
|
|
1054
|
+
"event_slug": "happy-event",
|
|
1055
|
+
"first_name": "Ada",
|
|
1056
|
+
"last_name": "Lovelace",
|
|
1057
|
+
"email": "ada@example.com",
|
|
1058
|
+
})
|
|
1059
|
+
.to_string();
|
|
1060
|
+
let response = registrations_route(&body, &payments::test_platform(), &client, &wasm_path, &config, &lambda_client::NeverInvoker, &crate::ir::fixture_payments()).await;
|
|
1061
|
+
|
|
1062
|
+
// Registration.Request refuses (no first_name/last_name in this
|
|
1063
|
+
// Attendee) but only after Payment.Initiate already ran, proving
|
|
1064
|
+
// display_name_from composed a real name Payment accepted.
|
|
1065
|
+
assert_eq!(response["statusCode"], 422, "{response:?}");
|
|
1066
|
+
assert!(
|
|
1067
|
+
response["body"].as_str().unwrap().contains("name"),
|
|
1068
|
+
"should surface CheckoutFixture's own real Attendee refusal, not a generic error: {response:?}"
|
|
1069
|
+
);
|
|
1070
|
+
|
|
1071
|
+
let read = dispatch::read(&client, &wasm_path).await.unwrap();
|
|
1072
|
+
let instances = read["instances"].as_object().unwrap();
|
|
1073
|
+
assert!(
|
|
1074
|
+
instances.keys().any(|k| k.starts_with("Payments::Payment#")),
|
|
1075
|
+
"Payment.Initiate should have committed for real using the composed display name: {instances:?}"
|
|
1076
|
+
);
|
|
1077
|
+
assert!(
|
|
1078
|
+
instances.keys().all(|k| !k.starts_with("CheckoutFixture::Registration#")),
|
|
1079
|
+
"Registration.Request must never have committed: {instances:?}"
|
|
1080
|
+
);
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
// A domain whose IR declares no payments capability serves none of the
|
|
1084
|
+
// checkout, registration-payment or webhook routes; the positive control
|
|
1085
|
+
// (the fixture's own IR) shows the same call is answered when it does.
|
|
1086
|
+
#[tokio::test]
|
|
1087
|
+
async fn payments_routes_serve_nothing_without_a_payments_capability() {
|
|
1088
|
+
let client = scratch_db("hecks_host_web_test_payments_gate").await;
|
|
1089
|
+
let wasm_path = checkout_wasm_path();
|
|
1090
|
+
let config = checkout_config(1);
|
|
1091
|
+
let no_payments = json!({"name": "Studio"});
|
|
1092
|
+
|
|
1093
|
+
for (method, path) in [
|
|
1094
|
+
("POST", "/registrations"),
|
|
1095
|
+
("GET", "/registrations/REG-1"),
|
|
1096
|
+
("POST", "/registrations/REG-1/complete"),
|
|
1097
|
+
("POST", "/webhooks/stripe"),
|
|
1098
|
+
("POST", "/events"),
|
|
1099
|
+
("GET", "/events/seats"),
|
|
1100
|
+
("GET", "/events/yoga/seats"),
|
|
1101
|
+
] {
|
|
1102
|
+
for ir in [Some(&no_payments), None] {
|
|
1103
|
+
let response = payments_routes(ir, method, path, "not json", "", &client, &wasm_path, &config, &lambda_client::NeverInvoker).await;
|
|
1104
|
+
assert!(response.is_none(), "{method} {path} answered although the IR declares no payments: {response:?}");
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
let fixture_ir = crate::ir::fixture_ir();
|
|
1109
|
+
let response = payments_routes(Some(&fixture_ir), "POST", "/registrations", "not json", "", &client, &wasm_path, &config, &lambda_client::NeverInvoker).await;
|
|
1110
|
+
assert_eq!(response.expect("the fixture IR declares payments, so the route answers")["statusCode"], 400);
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
#[tokio::test]
|
|
1114
|
+
async fn registrations_route_refuses_invalid_json_outright() {
|
|
1115
|
+
let client = scratch_db("hecks_host_web_test_registrations_bad_json").await;
|
|
1116
|
+
provision_lineage(&*client.lock().await, "CheckoutFixture", 1, &["Event", "Registration", "Payment"]).await;
|
|
1117
|
+
|
|
1118
|
+
let response = registrations_route("not json", &payments::test_platform(), &client, &checkout_wasm_path(), &checkout_config(1), &lambda_client::NeverInvoker, &crate::ir::fixture_payments()).await;
|
|
1119
|
+
assert_eq!(response["statusCode"], 400);
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
#[tokio::test]
|
|
1123
|
+
async fn events_route_refuses_invalid_json_outright() {
|
|
1124
|
+
let client = scratch_db("hecks_host_web_test_events_bad_json").await;
|
|
1125
|
+
provision_lineage(&*client.lock().await, "CheckoutFixture", 1, &["Event", "Registration", "Payment"]).await;
|
|
1126
|
+
|
|
1127
|
+
let response = events_route("not json", &client, &checkout_wasm_path(), &checkout_config(1), &lambda_client::NeverInvoker).await;
|
|
1128
|
+
assert_eq!(response["statusCode"], 400);
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
#[tokio::test]
|
|
1132
|
+
async fn events_route_refuses_a_body_missing_any_required_field() {
|
|
1133
|
+
let client = scratch_db("hecks_host_web_test_events_missing_fields").await;
|
|
1134
|
+
provision_lineage(&*client.lock().await, "CheckoutFixture", 1, &["Event", "Registration", "Payment"]).await;
|
|
1135
|
+
|
|
1136
|
+
let response = events_route(r#"{"slug":"new-event"}"#, &client, &checkout_wasm_path(), &checkout_config(1), &lambda_client::NeverInvoker).await;
|
|
1137
|
+
assert_eq!(response["statusCode"], 400);
|
|
1138
|
+
assert!(response["body"].as_str().unwrap().contains("missing name"));
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
#[tokio::test]
|
|
1142
|
+
async fn events_route_schedules_a_new_event_and_returns_201() {
|
|
1143
|
+
let client = scratch_db("hecks_host_web_test_events_new").await;
|
|
1144
|
+
provision_lineage(&*client.lock().await, "CheckoutFixture", 1, &["Event", "Registration", "Payment"]).await;
|
|
1145
|
+
let config = checkout_config(1);
|
|
1146
|
+
let wasm_path = checkout_wasm_path();
|
|
1147
|
+
|
|
1148
|
+
let body = json!({"slug": "mock-payments-event", "name": "Mock Payments Event", "price_cents": 4200, "capacity": 20}).to_string();
|
|
1149
|
+
let response = events_route(&body, &client, &wasm_path, &config, &lambda_client::NeverInvoker).await;
|
|
1150
|
+
assert_eq!(response["statusCode"], 201, "{response:?}");
|
|
1151
|
+
let response_body: Value = serde_json::from_str(response["body"].as_str().unwrap()).unwrap();
|
|
1152
|
+
assert_eq!(response_body["slug"]["value"], "mock-payments-event");
|
|
1153
|
+
assert_eq!(response_body["name"]["value"], "Mock Payments Event");
|
|
1154
|
+
assert_eq!(response_body["price"]["cents"], 4200);
|
|
1155
|
+
assert_eq!(response_body["capacity"]["value"], 20);
|
|
1156
|
+
assert_eq!(response_body["status"], "open");
|
|
1157
|
+
|
|
1158
|
+
let read = dispatch::read(&client, &wasm_path).await.unwrap();
|
|
1159
|
+
let instances = read["instances"].as_object().unwrap();
|
|
1160
|
+
assert!(
|
|
1161
|
+
instances.keys().any(|k| k == "CheckoutFixture::Event#mock-payments-event"),
|
|
1162
|
+
"Event.Schedule should have committed for real: {instances:?}"
|
|
1163
|
+
);
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
// A repeat call for the same slug is a 200 no-op returning the existing
|
|
1167
|
+
// event's state, never a second Event.Schedule dispatch.
|
|
1168
|
+
#[tokio::test]
|
|
1169
|
+
async fn events_route_is_idempotent_on_a_repeat_slug() {
|
|
1170
|
+
let client = scratch_db("hecks_host_web_test_events_idempotent").await;
|
|
1171
|
+
provision_lineage(&*client.lock().await, "CheckoutFixture", 1, &["Event", "Registration", "Payment"]).await;
|
|
1172
|
+
let config = checkout_config(1);
|
|
1173
|
+
let wasm_path = checkout_wasm_path();
|
|
1174
|
+
|
|
1175
|
+
let body = json!({"slug": "repeat-event", "name": "Repeat Event", "price_cents": 1000, "capacity": 5}).to_string();
|
|
1176
|
+
let first = events_route(&body, &client, &wasm_path, &config, &lambda_client::NeverInvoker).await;
|
|
1177
|
+
assert_eq!(first["statusCode"], 201, "{first:?}");
|
|
1178
|
+
|
|
1179
|
+
// A second call with different name/price/capacity still returns the
|
|
1180
|
+
// original event's state, 200 not 201 — idempotency keys only on slug.
|
|
1181
|
+
let second_body = json!({"slug": "repeat-event", "name": "Different Name", "price_cents": 9999, "capacity": 1}).to_string();
|
|
1182
|
+
let second = events_route(&second_body, &client, &wasm_path, &config, &lambda_client::NeverInvoker).await;
|
|
1183
|
+
assert_eq!(second["statusCode"], 200, "{second:?}");
|
|
1184
|
+
let second_body: Value = serde_json::from_str(second["body"].as_str().unwrap()).unwrap();
|
|
1185
|
+
assert_eq!(second_body["name"]["value"], "Repeat Event", "must return the ORIGINAL event, not re-schedule with the new fields");
|
|
1186
|
+
assert_eq!(second_body["price"]["cents"], 1000);
|
|
1187
|
+
|
|
1188
|
+
let read = dispatch::read(&client, &wasm_path).await.unwrap();
|
|
1189
|
+
let instances = read["instances"].as_object().unwrap();
|
|
1190
|
+
assert_eq!(
|
|
1191
|
+
instances.keys().filter(|k| k.starts_with("CheckoutFixture::Event#repeat-event")).count(),
|
|
1192
|
+
1,
|
|
1193
|
+
"exactly one Event, never a second dispatch: {instances:?}"
|
|
1194
|
+
);
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
#[tokio::test]
|
|
1198
|
+
async fn registrations_route_404s_an_unknown_event_slug() {
|
|
1199
|
+
let client = scratch_db("hecks_host_web_test_registrations_no_event").await;
|
|
1200
|
+
provision_lineage(&*client.lock().await, "CheckoutFixture", 1, &["Event", "Registration", "Payment"]).await;
|
|
1201
|
+
|
|
1202
|
+
let body = json!({"event_slug": "nope", "name": "Ada", "email": "ada@example.com"}).to_string();
|
|
1203
|
+
let response = registrations_route(&body, &payments::test_platform(), &client, &checkout_wasm_path(), &checkout_config(1), &lambda_client::NeverInvoker, &crate::ir::fixture_payments()).await;
|
|
1204
|
+
assert_eq!(response["statusCode"], 404);
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
#[tokio::test]
|
|
1208
|
+
async fn registrations_route_refuses_a_closed_event() {
|
|
1209
|
+
let client = scratch_db("hecks_host_web_test_registrations_closed_event").await;
|
|
1210
|
+
provision_lineage(&*client.lock().await, "CheckoutFixture", 1, &["Event", "Registration", "Payment"]).await;
|
|
1211
|
+
let config = checkout_config(1);
|
|
1212
|
+
let wasm_path = checkout_wasm_path();
|
|
1213
|
+
|
|
1214
|
+
schedule_event(&client, &wasm_path, &config, "closed-event", 4200).await;
|
|
1215
|
+
let close = dispatch::handle(&client, &wasm_path, "CheckoutFixture::Event.Close", json!({"id": "closed-event"}), None, &config, &lambda_client::NeverInvoker)
|
|
1216
|
+
.await
|
|
1217
|
+
.unwrap();
|
|
1218
|
+
assert!(close.accepted, "closing the fixture event should succeed: {:?}", close.result);
|
|
1219
|
+
|
|
1220
|
+
let body = json!({"event_slug": "closed-event", "name": "Ada", "email": "ada@example.com"}).to_string();
|
|
1221
|
+
let response = registrations_route(&body, &payments::test_platform(), &client, &wasm_path, &config, &lambda_client::NeverInvoker, &crate::ir::fixture_payments()).await;
|
|
1222
|
+
assert_eq!(response["statusCode"], 422);
|
|
1223
|
+
assert!(response["body"].as_str().unwrap().contains("closed"));
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
#[tokio::test]
|
|
1227
|
+
async fn registrations_route_propagates_a_real_domain_refusal_from_payment_initiate() {
|
|
1228
|
+
// A zero-price event: PositiveMoney's invariant refuses
|
|
1229
|
+
// Payment.Initiate before Registration.Request is ever reached,
|
|
1230
|
+
// proving the refusal is the real domain rule.
|
|
1231
|
+
let client = scratch_db("hecks_host_web_test_registrations_zero_price").await;
|
|
1232
|
+
provision_lineage(&*client.lock().await, "CheckoutFixture", 1, &["Event", "Registration", "Payment"]).await;
|
|
1233
|
+
let config = checkout_config(1);
|
|
1234
|
+
let wasm_path = checkout_wasm_path();
|
|
1235
|
+
|
|
1236
|
+
schedule_event(&client, &wasm_path, &config, "free-event", 0).await;
|
|
1237
|
+
|
|
1238
|
+
let body = json!({"event_slug": "free-event", "name": "Ada", "email": "ada@example.com"}).to_string();
|
|
1239
|
+
let response = registrations_route(&body, &payments::test_platform(), &client, &wasm_path, &config, &lambda_client::NeverInvoker, &crate::ir::fixture_payments()).await;
|
|
1240
|
+
assert_eq!(response["statusCode"], 422);
|
|
1241
|
+
assert!(
|
|
1242
|
+
response["body"].as_str().unwrap().contains("positive"),
|
|
1243
|
+
"should surface PositiveMoney's own invariant text: {response:?}"
|
|
1244
|
+
);
|
|
1245
|
+
|
|
1246
|
+
// And neither the payment nor the registration was persisted —
|
|
1247
|
+
// Registration.Request must never have been dispatched at all.
|
|
1248
|
+
let read = dispatch::read(&client, &wasm_path).await.unwrap();
|
|
1249
|
+
let instances = read["instances"].as_object().unwrap();
|
|
1250
|
+
assert!(instances.keys().all(|k| !k.starts_with("CheckoutFixture::Registration#") && !k.starts_with("Payments::Payment#")));
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
#[tokio::test]
|
|
1254
|
+
async fn registrations_route_runs_the_whole_dispatch_chain_and_returns_a_real_mock_checkout_url() {
|
|
1255
|
+
let client = scratch_db("hecks_host_web_test_registrations_happy_path").await;
|
|
1256
|
+
provision_lineage(&*client.lock().await, "CheckoutFixture", 1, &["Event", "Registration", "Payment"]).await;
|
|
1257
|
+
let config = checkout_config(1);
|
|
1258
|
+
let wasm_path = checkout_wasm_path();
|
|
1259
|
+
|
|
1260
|
+
schedule_event(&client, &wasm_path, &config, "happy-event", 4200).await;
|
|
1261
|
+
|
|
1262
|
+
// No connection: checkout is genuinely on the mock walkthrough, not
|
|
1263
|
+
// a misconfiguration — the whole chain runs and returns a real
|
|
1264
|
+
// checkout URL, never a 500.
|
|
1265
|
+
let body = json!({"event_slug": "happy-event", "name": "Ada Lovelace", "email": "ada@example.com"}).to_string();
|
|
1266
|
+
let response = registrations_route(&body, &payments::test_platform(), &client, &wasm_path, &config, &lambda_client::NeverInvoker, &crate::ir::fixture_payments()).await;
|
|
1267
|
+
assert_eq!(response["statusCode"], 200, "{response:?}");
|
|
1268
|
+
let body: Value = serde_json::from_str(response["body"].as_str().unwrap()).unwrap();
|
|
1269
|
+
let reference = body["registration_id"].as_str().unwrap().to_string();
|
|
1270
|
+
// LocalCheckout's shape: /pay/<reference>.html, with success_url and
|
|
1271
|
+
// cancel_url as encoded query params pointing at
|
|
1272
|
+
// registration-confirmed.html; no return_to sent, so it falls back to "/".
|
|
1273
|
+
assert_eq!(
|
|
1274
|
+
body["checkout_url"],
|
|
1275
|
+
format!(
|
|
1276
|
+
"http://localhost:4321/pay/{reference}.html?success_url=http%3A%2F%2Flocalhost%3A4321%2Fregistration-confirmed.html%3Fregistration_id%3D{reference}%26outcome%3Dsucceeded%26return_to%3D%252F&cancel_url=http%3A%2F%2Flocalhost%3A4321%2Fregistration-confirmed.html%3Fregistration_id%3D{reference}%26outcome%3Dcancelled%26return_to%3D%252F"
|
|
1277
|
+
)
|
|
1278
|
+
);
|
|
1279
|
+
|
|
1280
|
+
let read = dispatch::read(&client, &wasm_path).await.unwrap();
|
|
1281
|
+
let instances = read["instances"].as_object().unwrap();
|
|
1282
|
+
let registration = instances.iter().find(|(k, _)| k.starts_with("CheckoutFixture::Registration#")).map(|(_, v)| v);
|
|
1283
|
+
let payment = instances.iter().find(|(k, _)| k.starts_with("Payments::Payment#")).map(|(_, v)| v);
|
|
1284
|
+
assert!(registration.is_some(), "Registration.Request should have committed for real: {instances:?}");
|
|
1285
|
+
assert!(payment.is_some(), "Payment.Initiate should have committed for real: {instances:?}");
|
|
1286
|
+
|
|
1287
|
+
let registration = registration.unwrap();
|
|
1288
|
+
let payment = payment.unwrap();
|
|
1289
|
+
assert_eq!(registration["event_slug"], "happy-event");
|
|
1290
|
+
assert_eq!(registration["attendee"]["name"], "Ada Lovelace");
|
|
1291
|
+
// The Registration's id equals the Payment's reference, minted once
|
|
1292
|
+
// here, never independently.
|
|
1293
|
+
assert_eq!(registration["registration_id"]["value"], reference);
|
|
1294
|
+
assert_eq!(payment["reference"]["value"], reference);
|
|
1295
|
+
assert_eq!(payment["amount"]["cents"], 4200);
|
|
1296
|
+
// Mock, not "stripe": the processor checkout_plan picks with no
|
|
1297
|
+
// connection, which the webhook reads back off the Payment itself
|
|
1298
|
+
// for Payment::Succeed's "processor matches" given.
|
|
1299
|
+
assert_eq!(payment["processor"]["value"], "mock_stripe");
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
// Pins a real bug: a guest was redirected to the Event's internal slug
|
|
1303
|
+
// page (never a real route) instead of back to their own `return_to`,
|
|
1304
|
+
// 404ing every guest right after a successful, already-charged registration.
|
|
1305
|
+
#[tokio::test]
|
|
1306
|
+
async fn registrations_route_redirects_to_the_caller_s_own_return_to_not_the_event_slug() {
|
|
1307
|
+
let client = scratch_db("hecks_host_web_test_registrations_return_to").await;
|
|
1308
|
+
provision_lineage(&*client.lock().await, "CheckoutFixture", 1, &["Event", "Registration", "Payment"]).await;
|
|
1309
|
+
let config = checkout_config(1);
|
|
1310
|
+
let wasm_path = checkout_wasm_path();
|
|
1311
|
+
|
|
1312
|
+
schedule_event(&client, &wasm_path, &config, "happy-event", 4200).await;
|
|
1313
|
+
|
|
1314
|
+
let body = json!({
|
|
1315
|
+
"event_slug": "happy-event",
|
|
1316
|
+
"name": "Ada Lovelace",
|
|
1317
|
+
"email": "ada@example.com",
|
|
1318
|
+
"return_to": "/sample-studio.html",
|
|
1319
|
+
})
|
|
1320
|
+
.to_string();
|
|
1321
|
+
let response = registrations_route(&body, &payments::test_platform(), &client, &wasm_path, &config, &lambda_client::NeverInvoker, &crate::ir::fixture_payments()).await;
|
|
1322
|
+
assert_eq!(response["statusCode"], 200, "{response:?}");
|
|
1323
|
+
let body: Value = serde_json::from_str(response["body"].as_str().unwrap()).unwrap();
|
|
1324
|
+
let reference = body["registration_id"].as_str().unwrap().to_string();
|
|
1325
|
+
assert_eq!(
|
|
1326
|
+
body["checkout_url"],
|
|
1327
|
+
format!(
|
|
1328
|
+
"http://localhost:4321/pay/{reference}.html?success_url=http%3A%2F%2Flocalhost%3A4321%2Fregistration-confirmed.html%3Fregistration_id%3D{reference}%26outcome%3Dsucceeded%26return_to%3D%252Fsample-studio.html&cancel_url=http%3A%2F%2Flocalhost%3A4321%2Fregistration-confirmed.html%3Fregistration_id%3D{reference}%26outcome%3Dcancelled%26return_to%3D%252Fsample-studio.html"
|
|
1329
|
+
)
|
|
1330
|
+
);
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
// An absolute or protocol-relative return_to must never become an open
|
|
1334
|
+
// redirect; falls back to "/" exactly as if none had been sent.
|
|
1335
|
+
#[tokio::test]
|
|
1336
|
+
async fn registrations_route_refuses_an_absolute_or_protocol_relative_return_to() {
|
|
1337
|
+
let client = scratch_db("hecks_host_web_test_registrations_return_to_unsafe").await;
|
|
1338
|
+
provision_lineage(&*client.lock().await, "CheckoutFixture", 1, &["Event", "Registration", "Payment"]).await;
|
|
1339
|
+
let config = checkout_config(1);
|
|
1340
|
+
let wasm_path = checkout_wasm_path();
|
|
1341
|
+
|
|
1342
|
+
schedule_event(&client, &wasm_path, &config, "happy-event", 4200).await;
|
|
1343
|
+
|
|
1344
|
+
for unsafe_return_to in ["https://evil.example", "//evil.example"] {
|
|
1345
|
+
let body = json!({
|
|
1346
|
+
"event_slug": "happy-event",
|
|
1347
|
+
"name": "Ada Lovelace",
|
|
1348
|
+
"email": "ada@example.com",
|
|
1349
|
+
"return_to": unsafe_return_to,
|
|
1350
|
+
})
|
|
1351
|
+
.to_string();
|
|
1352
|
+
let response = registrations_route(&body, &payments::test_platform(), &client, &wasm_path, &config, &lambda_client::NeverInvoker, &crate::ir::fixture_payments()).await;
|
|
1353
|
+
assert_eq!(response["statusCode"], 200, "{response:?}");
|
|
1354
|
+
let body: Value = serde_json::from_str(response["body"].as_str().unwrap()).unwrap();
|
|
1355
|
+
// checkout_url is the /pay/<id>.html walkthrough page; return_to
|
|
1356
|
+
// is double-encoded (once for /pay's success_url, once more inside
|
|
1357
|
+
// it) so it shows up as "%252F".
|
|
1358
|
+
let checkout_url = body["checkout_url"].as_str().unwrap();
|
|
1359
|
+
let parsed = reqwest::Url::parse(checkout_url).unwrap();
|
|
1360
|
+
let success_url = parsed.query_pairs().find(|(k, _)| k == "success_url").map(|(_, v)| v.into_owned()).unwrap_or_default();
|
|
1361
|
+
assert!(
|
|
1362
|
+
success_url.ends_with("return_to=%2F"),
|
|
1363
|
+
"an unsafe return_to ({unsafe_return_to:?}) must fall back to \"/\": {body:?}"
|
|
1364
|
+
);
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
#[tokio::test]
|
|
1369
|
+
async fn a_mock_registration_confirms_end_to_end_through_a_synthetic_signed_webhook() {
|
|
1370
|
+
// The full loop, mock both ends: registrations_route's own
|
|
1371
|
+
// checkout_url, then a webhook signed against the same mock secret
|
|
1372
|
+
// webhook_route falls back to while STRIPE_WEBHOOK_SECRET is unset.
|
|
1373
|
+
let client = scratch_db("hecks_host_web_test_mock_full_loop").await;
|
|
1374
|
+
provision_lineage(&*client.lock().await, "CheckoutFixture", 1, &["Event", "Registration", "Payment"]).await;
|
|
1375
|
+
let config = checkout_config(1);
|
|
1376
|
+
let wasm_path = checkout_wasm_path();
|
|
1377
|
+
|
|
1378
|
+
schedule_event(&client, &wasm_path, &config, "mock-loop-event", 4200).await;
|
|
1379
|
+
|
|
1380
|
+
let body = json!({"event_slug": "mock-loop-event", "name": "Ada Lovelace", "email": "ada@example.com"}).to_string();
|
|
1381
|
+
let response = registrations_route(&body, &payments::test_platform(), &client, &wasm_path, &config, &lambda_client::NeverInvoker, &crate::ir::fixture_payments()).await;
|
|
1382
|
+
assert_eq!(response["statusCode"], 200, "{response:?}");
|
|
1383
|
+
let response_body: Value = serde_json::from_str(response["body"].as_str().unwrap()).unwrap();
|
|
1384
|
+
let reference = response_body["registration_id"].as_str().unwrap().to_string();
|
|
1385
|
+
|
|
1386
|
+
let secret = MOCK_STRIPE_WEBHOOK_SECRET;
|
|
1387
|
+
let payload = json!({
|
|
1388
|
+
"type": "checkout.session.completed",
|
|
1389
|
+
"data": {"object": {"id": format!("cs_manual_{reference}"), "metadata": {"registration_id": reference}}},
|
|
1390
|
+
}).to_string();
|
|
1391
|
+
let now = now_secs();
|
|
1392
|
+
let header = sign_stripe_header(secret, now, &payload);
|
|
1393
|
+
|
|
1394
|
+
let response = webhook_route(&payload, &header, &payments::test_platform(), &client, &wasm_path, &config, &lambda_client::NeverInvoker, &crate::ir::fixture_payments()).await;
|
|
1395
|
+
assert_eq!(response["statusCode"], 200, "{response:?}");
|
|
1396
|
+
|
|
1397
|
+
let read = dispatch::read(&client, &wasm_path).await.unwrap();
|
|
1398
|
+
let payment = &read["instances"][format!("Payments::Payment#{reference}")];
|
|
1399
|
+
assert_eq!(payment["status"], "succeeded");
|
|
1400
|
+
assert_eq!(payment["transaction_id"]["value"], format!("cs_manual_{reference}"));
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
#[tokio::test]
|
|
1404
|
+
async fn webhook_route_rejects_a_bad_signature_before_touching_the_domain_at_all() {
|
|
1405
|
+
let client = scratch_db("hecks_host_web_test_webhook_bad_sig").await;
|
|
1406
|
+
provision_lineage(&*client.lock().await, "CheckoutFixture", 1, &["Event", "Registration", "Payment"]).await;
|
|
1407
|
+
|
|
1408
|
+
let payload = json!({"type": "checkout.session.completed", "data": {"object": {}}}).to_string();
|
|
1409
|
+
let bad_header = "t=1700000000,v1=deadbeef";
|
|
1410
|
+
|
|
1411
|
+
let response = webhook_route(&payload, bad_header, &payments::test_platform_with_secret("whsec_test_bad_sig"), &client, &checkout_wasm_path(), &checkout_config(1), &lambda_client::NeverInvoker, &crate::ir::fixture_payments()).await;
|
|
1412
|
+
assert_eq!(response["statusCode"], 400);
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
#[tokio::test]
|
|
1416
|
+
async fn webhook_route_settles_a_payment_on_checkout_session_completed() {
|
|
1417
|
+
let client = scratch_db("hecks_host_web_test_webhook_completed").await;
|
|
1418
|
+
provision_lineage(&*client.lock().await, "CheckoutFixture", 1, &["Event", "Registration", "Payment"]).await;
|
|
1419
|
+
let config = checkout_config(1);
|
|
1420
|
+
let wasm_path = checkout_wasm_path();
|
|
1421
|
+
|
|
1422
|
+
schedule_event(&client, &wasm_path, &config, "webhook-event", 4200).await;
|
|
1423
|
+
let initiate = dispatch::handle(
|
|
1424
|
+
&client, &wasm_path, "Payments::Payment.Initiate",
|
|
1425
|
+
json!({
|
|
1426
|
+
"reference": {"value": "REG-WEBHOOK-1"}, "processor": {"value": "stripe"},
|
|
1427
|
+
"payment_type": {"value": "card"}, "amount": {"cents": 4200},
|
|
1428
|
+
"client": {"name": "Ada Lovelace", "email": "ada@example.com"},
|
|
1429
|
+
}),
|
|
1430
|
+
None, &config, &lambda_client::NeverInvoker,
|
|
1431
|
+
).await.unwrap();
|
|
1432
|
+
assert!(initiate.accepted, "{:?}", initiate.result);
|
|
1433
|
+
|
|
1434
|
+
let secret = "whsec_test_completed";
|
|
1435
|
+
let payload = json!({
|
|
1436
|
+
"type": "checkout.session.completed",
|
|
1437
|
+
"data": {"object": {"metadata": {"registration_id": "REG-WEBHOOK-1"}, "payment_intent": "pi_test_abc"}},
|
|
1438
|
+
}).to_string();
|
|
1439
|
+
let now = now_secs();
|
|
1440
|
+
let header = sign_stripe_header(secret, now, &payload);
|
|
1441
|
+
|
|
1442
|
+
let response = webhook_route(&payload, &header, &payments::test_platform_with_secret(secret), &client, &wasm_path, &config, &lambda_client::NeverInvoker, &crate::ir::fixture_payments()).await;
|
|
1443
|
+
assert_eq!(response["statusCode"], 200, "{response:?}");
|
|
1444
|
+
|
|
1445
|
+
let read = dispatch::read(&client, &wasm_path).await.unwrap();
|
|
1446
|
+
let payment = &read["instances"]["Payments::Payment#REG-WEBHOOK-1"];
|
|
1447
|
+
assert_eq!(payment["status"], "succeeded");
|
|
1448
|
+
assert_eq!(payment["transaction_id"]["value"], "pi_test_abc");
|
|
1449
|
+
|
|
1450
|
+
// A redelivered webhook (Stripe's delivery is at-least-once) for an
|
|
1451
|
+
// already-succeeded payment must still answer 200 — the refusal
|
|
1452
|
+
// underneath is a benign no-op, not surfaced as an error.
|
|
1453
|
+
let redelivered = webhook_route(&payload, &header, &payments::test_platform_with_secret(secret), &client, &wasm_path, &config, &lambda_client::NeverInvoker, &crate::ir::fixture_payments()).await;
|
|
1454
|
+
assert_eq!(redelivered["statusCode"], 200, "a redelivered webhook must not surface the resulting refusal as an error: {redelivered:?}");
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
#[tokio::test]
|
|
1458
|
+
async fn webhook_route_declines_a_payment_on_checkout_session_expired() {
|
|
1459
|
+
let client = scratch_db("hecks_host_web_test_webhook_expired").await;
|
|
1460
|
+
provision_lineage(&*client.lock().await, "CheckoutFixture", 1, &["Event", "Registration", "Payment"]).await;
|
|
1461
|
+
let config = checkout_config(1);
|
|
1462
|
+
let wasm_path = checkout_wasm_path();
|
|
1463
|
+
|
|
1464
|
+
schedule_event(&client, &wasm_path, &config, "webhook-event-2", 4200).await;
|
|
1465
|
+
dispatch::handle(
|
|
1466
|
+
&client, &wasm_path, "Payments::Payment.Initiate",
|
|
1467
|
+
json!({
|
|
1468
|
+
"reference": {"value": "REG-WEBHOOK-2"}, "processor": {"value": "stripe"},
|
|
1469
|
+
"payment_type": {"value": "card"}, "amount": {"cents": 4200},
|
|
1470
|
+
"client": {"name": "Ada Lovelace", "email": "ada@example.com"},
|
|
1471
|
+
}),
|
|
1472
|
+
None, &config, &lambda_client::NeverInvoker,
|
|
1473
|
+
).await.unwrap().accepted.then_some(()).expect("initiate should succeed");
|
|
1474
|
+
|
|
1475
|
+
let secret = "whsec_test_expired";
|
|
1476
|
+
let payload = json!({
|
|
1477
|
+
"type": "checkout.session.expired",
|
|
1478
|
+
"data": {"object": {"metadata": {"registration_id": "REG-WEBHOOK-2"}}},
|
|
1479
|
+
}).to_string();
|
|
1480
|
+
let now = now_secs();
|
|
1481
|
+
let header = sign_stripe_header(secret, now, &payload);
|
|
1482
|
+
|
|
1483
|
+
let response = webhook_route(&payload, &header, &payments::test_platform_with_secret(secret), &client, &wasm_path, &config, &lambda_client::NeverInvoker, &crate::ir::fixture_payments()).await;
|
|
1484
|
+
assert_eq!(response["statusCode"], 200, "{response:?}");
|
|
1485
|
+
|
|
1486
|
+
let read = dispatch::read(&client, &wasm_path).await.unwrap();
|
|
1487
|
+
let payment = &read["instances"]["Payments::Payment#REG-WEBHOOK-2"];
|
|
1488
|
+
assert_eq!(payment["status"], "failed");
|
|
1489
|
+
assert_eq!(payment["failure_reason"]["value"], "checkout_expired");
|
|
1490
|
+
}
|
|
1491
|
+
|
|
1492
|
+
#[tokio::test]
|
|
1493
|
+
async fn webhook_route_ignores_an_event_type_it_does_not_handle_and_still_answers_200() {
|
|
1494
|
+
let client = scratch_db("hecks_host_web_test_webhook_unhandled_type").await;
|
|
1495
|
+
provision_lineage(&*client.lock().await, "CheckoutFixture", 1, &["Event", "Registration", "Payment"]).await;
|
|
1496
|
+
|
|
1497
|
+
let secret = "whsec_test_unhandled";
|
|
1498
|
+
let payload = json!({"type": "charge.refunded", "data": {"object": {"metadata": {"registration_id": "whatever"}}}}).to_string();
|
|
1499
|
+
let now = now_secs();
|
|
1500
|
+
let header = sign_stripe_header(secret, now, &payload);
|
|
1501
|
+
|
|
1502
|
+
let response = webhook_route(&payload, &header, &payments::test_platform_with_secret(secret), &client, &checkout_wasm_path(), &checkout_config(1), &lambda_client::NeverInvoker, &crate::ir::fixture_payments()).await;
|
|
1503
|
+
assert_eq!(response["statusCode"], 200);
|
|
1504
|
+
}
|
|
1505
|
+
}
|