From 0f0fd7d304fcc420e5476054b0dec3245993e7c0 Mon Sep 17 00:00:00 2001 From: Steve Ardalis Smith Date: Fri, 22 May 2026 15:39:02 -0400 Subject: [PATCH] Upgrade project to .NET 10.0, refactor workflows, and enhance Stripe event handling --- .github/workflows/ci-cd.yml-archive | 4 +- .github/workflows/repro-bug.yml | 166 +++++++++++++----- DevBetterWeb.sln | Bin 18360 -> 0 bytes Dockerfile | 6 +- .../Services/CreateVideoService.cs | 1 - .../StripePaymentHandlerEventService.cs | 4 +- .../StripePaymentHandlerInvoiceService.cs | 2 +- ...ymentHandlerSubscriptionCreationService.cs | 7 +- ...StripePaymentHandlerSubscriptionService.cs | 4 +- src/DevBetterWeb.Web/Dockerfile | 8 +- .../CustomerSubscriptionDeletedWebHook.cs | 2 +- .../CustomerSubscriptionUpdatedWebHook.cs | 2 +- .../InvoicePaidWebHook.cs | 2 +- .../PaymentIntentSucceededWebHook.cs | 2 +- .../MappingProfiles/InvoiceProfile.cs | 6 +- src/DevBetterWeb.Web/Program.cs | 19 +- .../Stripe/EventUtilityParseEvent.cs | 4 +- .../Integration/Stripe/stripeJson1.json | 8 +- 18 files changed, 168 insertions(+), 79 deletions(-) delete mode 100644 DevBetterWeb.sln diff --git a/.github/workflows/ci-cd.yml-archive b/.github/workflows/ci-cd.yml-archive index a71a25bb8..e52730ae0 100644 --- a/.github/workflows/ci-cd.yml-archive +++ b/.github/workflows/ci-cd.yml-archive @@ -72,13 +72,13 @@ jobs: id: build_solution shell: pwsh run: | - dotnet build ./DevBetterWeb.sln --configuration Release + dotnet build ./DevBetterWeb.slnx --configuration Release - name: Run unit tests id: run_unit_tests shell: pwsh run: | - dotnet test ./DevBetterWeb.sln --filter FullyQualifiedName!~Vimeo.Tests --configuration Release --no-build + dotnet test ./DevBetterWeb.slnx --filter FullyQualifiedName!~Vimeo.Tests --configuration Release --no-build - name: Publish WebApp id: publish_webapp shell: pwsh diff --git a/.github/workflows/repro-bug.yml b/.github/workflows/repro-bug.yml index b116ee75d..cf47a5aba 100644 --- a/.github/workflows/repro-bug.yml +++ b/.github/workflows/repro-bug.yml @@ -27,36 +27,57 @@ permissions: actions: read pull-requests: read +defaults: + run: + shell: bash + env: DOTNET_VERSION: 10.0.x + DOTNET_CONFIGURATION: Release # Set one or both. SOLUTION_FILE: DevBetterWeb.slnx PROJECT_FILE: src/DevBetterWeb.Web/DevBetterWeb.Web.csproj APP_URL: http://127.0.0.1:5010 - APP_STARTUP_COMMAND: dotnet run --project src/DevBetterWeb.Web/DevBetterWeb.Web.csproj --no-build --launch-profile "DevBetterWeb.Web - DEV" + APP_HEALTH_PATH: /health + APP_STARTUP_COMMAND: dotnet run --project src/DevBetterWeb.Web/DevBetterWeb.Web.csproj --configuration Release --no-build --no-launch-profile jobs: reproduce-bug: + # Only run on: + # - workflow_dispatch (maintainer-initiated), OR + # - pull_request labeled 'bug' from a branch in THIS repo (never a fork — + # we build & run PR code, so external PRs would be a code-execution risk). if: > - (github.event_name == 'pull_request' && github.event.label.name == 'bug') || - github.event_name == 'workflow_dispatch' + github.event_name == 'workflow_dispatch' || + (github.event_name == 'pull_request' && + github.event.label.name == 'bug' && + github.event.pull_request.head.repo.full_name == github.repository) runs-on: ubuntu-latest timeout-minutes: 30 concurrency: - group: repro-bug-${{ github.event_name }}-${{ github.event.pull_request.number || github.event.inputs.issue_number || github.run_id }} + group: repro-bug-${{ github.event.pull_request.number || github.event.inputs.issue_number }} cancel-in-progress: true steps: - name: Resolve target context id: target - uses: actions/github-script@v7 + # Pinned to v7.0.1 commit SHA for supply-chain safety. + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea with: script: | const isDispatch = context.eventName === 'workflow_dispatch'; const inputs = context.payload.inputs || {}; + // Truncate untrusted text so it can't blow past env-var size limits + // or overwhelm downstream consumers. + const MAX_LEN = 8000; + const truncate = (s) => { + const v = (s || '').toString(); + return v.length > MAX_LEN ? v.slice(0, MAX_LEN) + '\n…[truncated]' : v; + }; + let issueNumber; let issueTitle; let issueBody; @@ -91,6 +112,12 @@ jobs: repo: context.repo.repo, pull_number: issueNumber }); + // Refuse to build code from a fork via dispatch as well. + const expected = `${context.repo.owner}/${context.repo.repo}`; + if (prResponse.data.head.repo.full_name !== expected) { + core.setFailed(`PR #${issueNumber} is from a fork; refusing to check out and build untrusted code.`); + return; + } checkoutRef = prResponse.data.head.sha; } else { checkoutRef = inputs.target_ref || 'main'; @@ -117,36 +144,53 @@ jobs: } core.exportVariable('ISSUE_NUMBER', String(issueNumber)); - core.exportVariable('ISSUE_TITLE', issueTitle); - core.exportVariable('ISSUE_BODY', issueBody); + core.exportVariable('ISSUE_TITLE', truncate(issueTitle)); + core.exportVariable('ISSUE_BODY', truncate(issueBody)); core.exportVariable('ISSUE_URL', issueUrl); core.exportVariable('REPO_NAME', context.repo.owner + '/' + context.repo.repo); core.setOutput('checkout_ref', checkoutRef); core.setOutput('issue_number', String(issueNumber)); - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: ref: ${{ steps.target.outputs.checkout_ref }} + # Don't leave the GITHUB_TOKEN in .git/config — we only need to read. + persist-credentials: false - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@cdca7365b2dadb8aad0a33bc7601856ffabcc48e # v4.3.0 with: node-version: 22 - name: Setup .NET - uses: actions/setup-dotnet@v4 + uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1 with: dotnet-version: ${{ env.DOTNET_VERSION }} + - name: Cache Playwright browsers + id: playwright-cache + uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.0 + with: + path: ~/.cache/ms-playwright + key: playwright-${{ runner.os }}-v1 + - name: Initialize Playwright project + timeout-minutes: 5 run: | - npm init -y - npm install @playwright/test wait-on - npx playwright install --with-deps + set -euo pipefail + npm init -y >/dev/null + npm install --no-fund --no-audit @playwright/test wait-on + if [ "${{ steps.playwright-cache.outputs.cache-hit }}" = "true" ]; then + npx playwright install-deps + else + npx playwright install --with-deps + fi - name: Restore .NET dependencies + timeout-minutes: 10 run: | + set -euo pipefail if [ -n "${SOLUTION_FILE}" ] && [ -f "${SOLUTION_FILE}" ]; then dotnet restore "${SOLUTION_FILE}" elif [ -n "${PROJECT_FILE}" ] && [ -f "${PROJECT_FILE}" ]; then @@ -156,28 +200,56 @@ jobs: fi - name: Build application + timeout-minutes: 15 run: | + set -euo pipefail if [ -n "${SOLUTION_FILE}" ] && [ -f "${SOLUTION_FILE}" ]; then - dotnet build "${SOLUTION_FILE}" --no-restore + dotnet build "${SOLUTION_FILE}" --configuration "${DOTNET_CONFIGURATION}" --no-restore elif [ -n "${PROJECT_FILE}" ] && [ -f "${PROJECT_FILE}" ]; then - dotnet build "${PROJECT_FILE}" --no-restore + dotnet build "${PROJECT_FILE}" --configuration "${DOTNET_CONFIGURATION}" --no-restore else - dotnet build --no-restore + dotnet build --configuration "${DOTNET_CONFIGURATION}" --no-restore fi - name: Start application run: | - ${APP_STARTUP_COMMAND} > app.log 2>&1 & + set -euo pipefail + export ASPNETCORE_URLS="${APP_URL}" + export ASPNETCORE_ENVIRONMENT="Development" + # setsid puts the app in its own process group so cleanup can kill + # the whole tree (dotnet often spawns child processes). + setsid bash -c "${APP_STARTUP_COMMAND} > app.log 2>&1" & echo $! > app.pid + echo "Started app with PGID $(cat app.pid)" - name: Wait for application startup + timeout-minutes: 3 run: | - npx wait-on "${APP_URL}" --timeout 120000 + set -uo pipefail + # Prefer a real health endpoint over a bare HTTP check; wait-on with + # http-get:// considers only 2xx as ready. + TARGET="http-get://127.0.0.1:5010${APP_HEALTH_PATH}" + if ! npx wait-on "${TARGET}" --timeout 120000 --interval 1000; then + echo "::warning::Health endpoint not ready at ${TARGET}; falling back to base URL." + if ! npx wait-on "http-get://127.0.0.1:5010/" --timeout 30000 --interval 1000; then + echo "::error::Application failed to become ready." + if [ -f app.pid ]; then + echo "Process status:" + ps -p "$(cat app.pid)" -f || true + fi + echo "Last 200 lines of app.log:" + tail -n 200 app.log || true + exit 1 + fi + fi - name: Generate reproduction script run: | + set -euo pipefail mkdir -p tests + # NOTE: ISSUE_TITLE / ISSUE_BODY are UNTRUSTED user input. + # Only log them — never pass them to page.goto(), eval(), or shell. cat << 'EOF' > tests/repro.spec.js const { test } = require('@playwright/test'); @@ -203,12 +275,12 @@ jobs: EOF - name: Run Playwright reproduction - run: | - npx playwright test --reporter=list + timeout-minutes: 10 + run: npx playwright test --reporter=list - name: Upload Playwright artifacts if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: repro-artifacts-${{ steps.target.outputs.issue_number }} path: | @@ -219,35 +291,35 @@ jobs: retention-days: 14 - name: Comment on issue or PR with artifact link - if: always() - uses: actions/github-script@v7 + if: always() && env.ISSUE_NUMBER != '' + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 with: script: | const runUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}` + `/actions/runs/${context.runId}`; - const body = ` - 🤖 Automated reproduction attempt completed. - - ## Target - #${process.env.ISSUE_NUMBER} - - ## Configuration - - Solution: \`${process.env.SOLUTION_FILE || '(not set)'}\` - - Project: \`${process.env.PROJECT_FILE || '(not set)'}\` - - App URL: \`${process.env.APP_URL}\` - - ## Artifacts - - 🎥 Playwright video - - 📷 Screenshots - - 🧭 Trace files - - 📄 Console output - - Download artifacts from the workflow run: - - ${runUrl} - `; + const body = [ + '🤖 Automated reproduction attempt completed.', + '', + '## Target', + `#${process.env.ISSUE_NUMBER}`, + '', + '## Configuration', + `- Solution: \`${process.env.SOLUTION_FILE || '(not set)'}\``, + `- Project: \`${process.env.PROJECT_FILE || '(not set)'}\``, + `- App URL: \`${process.env.APP_URL}\``, + '', + '## Artifacts', + '- 🎥 Playwright video', + '- 📷 Screenshots', + '- 🧭 Trace files', + '- 📄 Console output', + '', + 'Download artifacts from the workflow run:', + '', + runUrl + ].join('\n'); await github.rest.issues.createComment({ owner: context.repo.owner, @@ -260,5 +332,9 @@ jobs: if: always() run: | if [ -f app.pid ]; then - kill $(cat app.pid) || true + PGID="$(cat app.pid)" + # Kill the entire process group started via setsid. + kill -TERM -"${PGID}" 2>/dev/null || true + sleep 2 + kill -KILL -"${PGID}" 2>/dev/null || true fi diff --git a/DevBetterWeb.sln b/DevBetterWeb.sln deleted file mode 100644 index 1059884116a7a8ca632a4c231809b7838ea25295..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 18360 zcmdU%OK%%D6ot7>f&Pae-K0Pz$g-Y`F7$F5pb3nmNjKdjwrn(wAF%8of!n{{_B*6# zNuwFgIGPzV5G2o#yyW4zFH#=rzkk0s-1kh zrgY!Rt;?)qOSb=+{`XAZj@UH&T=#ejfv!8YFU|g(_Zc%f+dz}wb8FdihxxSWamf9i z?e);|`2qY#@bJ_;F}JowmzFa|*Q;R$FgG<5t~%6?Y}t_OKJNxSsbpxnpq^5q&lxiR z(A$s9OtX6ew^xku#B!g!yQPm7awFu1_NqZ|E&A`;F;zR_YD{g1whjJ@p94nO<29gk zfb7hUDUHqG>ey_~PUV{=RnGSsP8V>xxSR8K$t^RIhR5LVGpD{~bIR3$op+mA4Y(RG z|AFOyY#9>YVtr^!`|vHU?>p}yJ;)Zs?!FySc6VS=NQ*r?wnprNPn@g$jQSDxYS-n} zOXzvZygxCcIdj*Hugvd!zQ^Jwc;OY-n>O$V=<_6sv}s~p3v;=&XFtX*NQGY3F~(wA zONPa{thvuiS0m=rqfG-e)8)Lnuri{wi}e0C!SHje{saczS5s_sNoF`L~PhiX!rC)yfA(w2%k1d3R{j|0XQ|H)2Ruv<)%1 zk}K9Ki)&@B8BEJIM>e8Nxf?85AH%yc*%*G6gT-}=vI(Ecp)Pm!xc{0su8dxOzCN~5 z`GSbL%3x__w~Q@+?OW?{HldAJT&@OH%oMRUb&?N}5I zro?jXS4Ol^X4|K|Rv%i&slAT|X_}G_({^Wd>)?R(@;UbE^Nl?0YxNQCE2NvSWR{-V zL}sltv@>=qXYCxdkCC1ft=-;P`jYOnOII$|3Q&<-d%h8W^Yru#PJHxnzPDbonHjh9 zwf2G9rzk#aZ=!EtTy8(4U)`P1Lf_3KGkP+2`KX{Blky7F|10Wv-&uEzQdzrDLS`U;eao!XtvGmwVc__33%))A-IWlOG*Z%eMw zJCDuQvg<#){%OLuzs?4fJ+8Obe|~Od?)97a!%B~vHr2$&TzvRMn+ewH*BRL|^+kFznB9aG34aMe&vQt!{OBxGs!owpe7HP!+yBKaK%ge4K^-_F*~8 z_Sd>`h2z8SdALlU$itdugJGFf9!SF|dpe}LJX{yXG+QhZAFINb=f^Q%i;uIg-##p7+5TELu5f(VJr9@Z6M0zE zY%nad%EMLS%=6!vFviPa*q`IzFx#(-;w$etmg@3wT^Q4Bu}FNZ3SXWd$AB$9&cc5C zu$*Q4Yu&iQ@nQEoT&7RtVNJ8au*@nCSBW#ve`CTJFNa}&j)TK&zb=Zeyyq0F%foeH zOtZxz@v$m=d43!Nw)i*;`|ZPWmhG=~;|j-z-ScpnK9Pqt%?86Vt2|sK&OHB(31hq* zhW$AX4zvBb2)^B)=uSS3?@n)A@=WxVeWG6{RyI%MB_+H^{KAvU$H*n?UvdsW4ZDFP*m5Zzj0kZ*ix2bqY+UdG+feIt!+AybW6GM6^!(c6jN0 T-;ijbQ((@TiZHO HandleAsync(CancellationToken cancellat _logger.LogInformation($"Processing Stripe Event Type: {stripeEvent.Type}"); - if (stripeEvent.Type != Events.CustomerDeleted) + if (stripeEvent.Type != EventTypes.CustomerDeleted) { throw new Exception($"Unhandled Stripe event type {stripeEvent.Type}"); } diff --git a/src/DevBetterWeb.Web/Endpoints/StripeWebhookEndpoints/CustomerSubscriptionUpdatedWebHook.cs b/src/DevBetterWeb.Web/Endpoints/StripeWebhookEndpoints/CustomerSubscriptionUpdatedWebHook.cs index 842a233fe..4d0b23f30 100644 --- a/src/DevBetterWeb.Web/Endpoints/StripeWebhookEndpoints/CustomerSubscriptionUpdatedWebHook.cs +++ b/src/DevBetterWeb.Web/Endpoints/StripeWebhookEndpoints/CustomerSubscriptionUpdatedWebHook.cs @@ -57,7 +57,7 @@ public override async Task HandleAsync(CancellationToken cancellat _logger.LogInformation($"Processing Stripe Event Type: {stripeEvent.Type}"); - if (stripeEvent.Type != Events.CustomerUpdated) + if (stripeEvent.Type != EventTypes.CustomerUpdated) { throw new Exception($"Unhandled Stripe event type {stripeEvent.Type}"); } diff --git a/src/DevBetterWeb.Web/Endpoints/StripeWebhookEndpoints/InvoicePaidWebHook.cs b/src/DevBetterWeb.Web/Endpoints/StripeWebhookEndpoints/InvoicePaidWebHook.cs index 79d91ffa8..a06902a74 100644 --- a/src/DevBetterWeb.Web/Endpoints/StripeWebhookEndpoints/InvoicePaidWebHook.cs +++ b/src/DevBetterWeb.Web/Endpoints/StripeWebhookEndpoints/InvoicePaidWebHook.cs @@ -55,7 +55,7 @@ public override async Task HandleAsync(CancellationToken cancellat _logger.LogInformation($"Processing Stripe Event Type: {stripeEvent.Type}"); // Was InvoicePaymentSucceeded changed to InvoicePaid - if (stripeEvent.Type != Events.InvoicePaid) + if (stripeEvent.Type != EventTypes.InvoicePaid) { throw new Exception($"Unhandled Stripe event type {stripeEvent.Type}"); } diff --git a/src/DevBetterWeb.Web/Endpoints/StripeWebhookEndpoints/PaymentIntentSucceededWebHook.cs b/src/DevBetterWeb.Web/Endpoints/StripeWebhookEndpoints/PaymentIntentSucceededWebHook.cs index 2714a9dd9..988c49db4 100644 --- a/src/DevBetterWeb.Web/Endpoints/StripeWebhookEndpoints/PaymentIntentSucceededWebHook.cs +++ b/src/DevBetterWeb.Web/Endpoints/StripeWebhookEndpoints/PaymentIntentSucceededWebHook.cs @@ -48,7 +48,7 @@ public override async Task HandleAsync(CancellationToken cancellat _logger.LogInformation($"Processing Stripe Event Type: {stripeEvent.Type}"); - if (stripeEvent.Type != Events.PaymentIntentSucceeded) + if (stripeEvent.Type != EventTypes.PaymentIntentSucceeded) { throw new Exception($"Unhandled Stripe event type {stripeEvent.Type}"); } diff --git a/src/DevBetterWeb.Web/MappingProfiles/InvoiceProfile.cs b/src/DevBetterWeb.Web/MappingProfiles/InvoiceProfile.cs index bf8907768..8f4796f97 100644 --- a/src/DevBetterWeb.Web/MappingProfiles/InvoiceProfile.cs +++ b/src/DevBetterWeb.Web/MappingProfiles/InvoiceProfile.cs @@ -1,4 +1,4 @@ -using AutoMapper; +using AutoMapper; using DevBetterWeb.Web.Models; using Stripe; @@ -10,9 +10,9 @@ public InvoiceProfile() { CreateMap() .ForPath(dest => dest.IsPaid, - opt => opt.MapFrom(source => source.Paid)) + opt => opt.MapFrom(source => source.Status == "paid")) .ForPath(dest => dest.IsPaidOutOfBand, - opt => opt.MapFrom(source => source.PaidOutOfBand)) + opt => opt.MapFrom(source => false)) .ForPath(dest => dest.FinalizedAt, opt => opt.MapFrom(source => source.StatusTransitions.FinalizedAt)) .ForPath(dest => dest.PaidAt, diff --git a/src/DevBetterWeb.Web/Program.cs b/src/DevBetterWeb.Web/Program.cs index b9d78115d..0e8dc199e 100644 --- a/src/DevBetterWeb.Web/Program.cs +++ b/src/DevBetterWeb.Web/Program.cs @@ -26,7 +26,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; -using Microsoft.OpenApi.Models; +using Microsoft.OpenApi; using NimblePros.Metronome; using NimblePros.Vimeo.Extensions; using Serilog; @@ -103,8 +103,7 @@ builder.Services.AddStripeServices( builder.Configuration.GetSection("StripeOptions")["StripeSecretKey"]!); -var webProjectAssembly = typeof(Program).Assembly; -builder.Services.AddAutoMapper(webProjectAssembly); +builder.Services.AddAutoMapper(cfg => cfg.AddMaps(typeof(Program).Assembly)); builder.Services.AddMetronome(); @@ -153,6 +152,8 @@ builder.Services.AddHttpClient(); +builder.Services.AddHealthChecks(); + builder.Services.AddMvc() .AddControllersAsServices() .AddRazorRuntimeCompilation(); @@ -162,10 +163,14 @@ c.SwaggerDoc("v1", new OpenApiInfo { Title = "My API", Version = "v1" }); }); -builder.Services.AddApplicationInsightsTelemetry(options => +var appInsightsConnectionString = builder.Configuration["APPINSIGHTS_CONNECTIONSTRING"]; +if (!string.IsNullOrWhiteSpace(appInsightsConnectionString)) { - options.ConnectionString = builder.Configuration["APPINSIGHTS_CONNECTIONSTRING"]; -}); + builder.Services.AddApplicationInsightsTelemetry(options => + { + options.ConnectionString = appInsightsConnectionString; + }); +} var app = builder.Build(); @@ -201,6 +206,8 @@ }); } +app.MapHealthChecks("/health"); + app.MapRazorPages(); app.UseStaticFiles(); diff --git a/tests/DevBetterWeb.Tests/Integration/Stripe/EventUtilityParseEvent.cs b/tests/DevBetterWeb.Tests/Integration/Stripe/EventUtilityParseEvent.cs index 093261516..504b5479b 100644 --- a/tests/DevBetterWeb.Tests/Integration/Stripe/EventUtilityParseEvent.cs +++ b/tests/DevBetterWeb.Tests/Integration/Stripe/EventUtilityParseEvent.cs @@ -11,10 +11,10 @@ public class EventUtilityParseEvent public void ParseJsonToSubscriptionId() { string json = System.IO.File.ReadAllText(_jsonFile); - var stripeEvent = EventUtility.ParseEvent(json); + var stripeEvent = EventUtility.ParseEvent(json, throwOnApiVersionMismatch: false); var invoice = (Invoice)stripeEvent.Data.Object; - string subscriptionId = invoice.SubscriptionId; + string subscriptionId = invoice.Parent?.SubscriptionDetails?.SubscriptionId ?? string.Empty; Assert.Equal("sub_K1hzaxOt9gb2TB", subscriptionId); } } diff --git a/tests/DevBetterWeb.Tests/Integration/Stripe/stripeJson1.json b/tests/DevBetterWeb.Tests/Integration/Stripe/stripeJson1.json index e4b9beb6b..46aaec7d5 100644 --- a/tests/DevBetterWeb.Tests/Integration/Stripe/stripeJson1.json +++ b/tests/DevBetterWeb.Tests/Integration/Stripe/stripeJson1.json @@ -162,7 +162,13 @@ "paid_at": 1628777276, "voided_at": null }, - "subscription": "sub_K1hzaxOt9gb2TB", + "parent": { + "type": "subscription_details", + "subscription_details": { + "subscription": "sub_K1hzaxOt9gb2TB", + "metadata": {} + } + }, "subtotal": 20000, "tax": null, "total": 20000,