Vibecode WooCommerce Subscriptions
track this build5 phases, 15 steps, beginner friendly0%The actual hard part of recurring billing lives at the payment processor, not in this plugin: Stripe Billing already does tokenization, renewal attempts, SCA, proration and dunning. So an agent can absolutely wire a small self-hosted checkout plus webhook listener plus entitlement table in a weekend, and for one product with two or three plans that is genuinely enough. What you cannot one-shot is the part that makes this extension worth a license: living inside WooCommerce so that orders, coupons, taxes, shipping, emails, reports and forty other plugins all understand a subscription. If you already run a real Woo store, replacing this means rebuilding the glue, not the billing. If you just want money to arrive monthly for something you built, you never needed WordPress in the first place.
You are building a lean indie version of WooCommerce Subscriptions. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== README.md ===== # WooCommerce Subscriptions · indie build A small set of plans sold through hosted checkout, webhooks as the only writer of entitlement, renewals that handle an authentication requirement by asking the customer rather than failing silently, a dunning sequence with a grace period, and self-service cancellation that does not need you. Estimated effort: **weekend**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | PHP with WordPress, or any small server | the point is the billing state machine rather than the framework; use what the store already runs on | | Payments | Hosted checkout with saved payment methods and webhooks | card details never touch you and the provider handles the authentication flow | | Money | Integer minor units | proration and partial periods produce fractional amounts that floating point rounds wrongly | | Entitlement | One table consulted by every gate | access decided in one place is access you can reason about | | Email | A transactional provider with a verified domain | an authentication request in a spam folder is a cancelled customer | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **A working store or a small server** · free if you already have one - Why: This is billing logic bolted to something that already exists. Building a store first is a different project. - Get it: Use the store you already run, or a small application with user accounts. What matters is that there is something to gate. - Verify: A gated page exists that you can protect - [ ] **A payment provider account with test mode and webhooks** · free in test mode; live billing costs per transaction plus a further fee on subscriptions in most regions - Why: Hosted checkout, saved cards, subscription events and the authentication flow all come from here. - Get it: Sign up, create prices for your plans, take the secret key and the webhook signing secret. - Verify: A triggered subscription event reaches your endpoint and is verified - [ ] **Test cards that force authentication and failure** · free - Why: Phase 3 is the whole point of this build and it cannot be tested with a card that always succeeds. - Get it: Your provider publishes test card numbers including ones that always require authentication and ones that fail on renewal. Find them before Phase 3. - Verify: A renewal produced a requires-action state in test mode - [ ] **A transactional email provider with a verified domain** · free tiers cover this volume - Why: The authentication link and the dunning sequence both depend on delivery, and one in spam is a lost customer. - Get it: Create an account, add the DNS records they specify, take the credentials. - Verify: A test email arrives at three providers and is not in spam - [ ] **Your grace period and dunning schedule** · free - Why: How long a customer keeps access after a failed payment is a commercial decision rather than a technical one, and it should be decided before it is implemented. - Get it: Decide the grace period, the number of retries and when access ends. Write it down and put it in your terms. - Verify: The policy is written and matches the configuration ## Quick start ```sh stripe trigger charge.refunded ``` Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - The plugin's breadth of subscription types and store configurations. - Tax determination and remittance. - Migrating an existing subscriber base between processors. - Vendor support when something breaks mid-renewal-cycle. - Integration with the WooCommerce order, coupon, tax and shipping machinery, so subscriptions are no longer just orders your other plugins understand - Gateway breadth: you are married to one processor instead of the dozen Woo supports - Customer-facing plan switching, pausing, resubscribing and prorated upgrades in your own UI, unless you accept the hosted portal as-is - Admin reporting, renewal calendars and bulk actions over thousands of subscribers - Years of edge case handling for partial renewals, mixed carts, manual renewals and payment method migrations If one of those is essential to you, that is the reason to keep paying for WooCommerce Subscriptions, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · WooCommerce Subscriptions The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me recurring billing for a handful of plans, where a renewal that needs authentication does not silently fail to replace WooCommerce Subscriptions. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero The thing that separates working recurring billing from a demo is the renewal nobody is present for. A saved card charged off session can be declined with an authentication requirement, and in Europe that is a routine category of decline rather than an edge case: strong customer authentication is the rule and the three domain secure prompt is the mechanism. When it happens the payment sits waiting for a customer who is not there. So the design point is not taking the first payment, it is handling the renewal that cannot complete: detect the pending state, email the customer a link to authenticate, keep access during a grace period, and only then lapse. Building this after launch means customers losing access for a bank prompt they never saw. ### Stack (fixed, do not substitute) - Runtime: PHP with WordPress, or any small server · the point is the billing state machine rather than the framework; use what the store already runs on - Payments: Hosted checkout with saved payment methods and webhooks · card details never touch you and the provider handles the authentication flow - Money: Integer minor units · proration and partial periods produce fractional amounts that floating point rounds wrongly - Entitlement: One table consulted by every gate · access decided in one place is access you can reason about - Email: A transactional provider with a verified domain · an authentication request in a spam folder is a cancelled customer ### Data model (create this before Phase 1) - plans(id, name, price_minor, currency, interval, provider_price_id, active) - customers(id, email, provider_customer_id, created_at) - subscriptions(id, customer_id, plan_id, provider_subscription_id, state, current_period_end, cancel_at_period_end, grace_until) - invoices(id, subscription_id, provider_invoice_id, amount_minor, state, attempted_at, authentication_url, paid_at) - entitlements(customer_id, plan_id, granted_at, revoked_at, reason) · the only thing any access check reads - webhook_events(provider_event_id, type, processed_at) · idempotency, because events are redelivered - State is derived from invoices and entitlements rather than set by hand, so the database cannot disagree with itself. ### Phase 1 · Entitlement as the single gate Build: An entitlements table and one function answering whether a customer has access to a plan. Every gated route and template calls it. Grant entitlements by hand for now, so gating is proven before billing complicates it. Trap: Checking a subscription's state at each gate spreads billing logic through the application, and the first time a state is added, half the gates are wrong. One entitlement table, one function, and subscriptions write to it rather than being consulted. Done when: Every gated route is refused without an entitlement, verified by a test enumerating them rather than checking a few by hand. Granting an entitlement opens exactly its plan. Revoking closes access immediately. No gate reads a subscription state directly. Do not build yet: No checkout, renewals or dunning. ### Phase 2 · First payment and the webhook Build: Hosted checkout for each plan, created server side from your own price. A webhook that verifies its signature, records the event identifier for idempotency, and writes the subscription and entitlement. The success redirect grants nothing. Trap: Granting on the success redirect hands out access to anyone who can replay a URL, and the customer may close the tab before it loads anyway. The webhook is the truth and the redirect is a thank-you page that must handle arriving before the entitlement exists. Done when: A test subscription grants entitlement only after the webhook. Replaying the event leaves one entitlement. A bad signature is rejected with a client error. Closing the tab immediately after paying still results in access once the webhook arrives. Do not build yet: No renewals or authentication handling yet. ### Phase 3 · The renewal that needs the customer Build: Handle the invoice event that reports payment requires action. Store the authentication link, email it to the customer, and keep access during a grace period. Complete the subscription when they authenticate, and lapse only after the grace period ends. Trap: Treating a payment that requires action as a failure and revoking access immediately punishes a customer for their bank's prompt, which they have not seen because they were not present. Expect this state as routine, keep access, ask them, and give them time. Done when: An invoice in the requires-action state emails an authentication link and does not revoke access. Completing the authentication marks the invoice paid and clears the grace period. Access persists through the grace period and lapses after it. The pending state is visible in the customer's account page. Do not build yet: No dunning sequence or cancellation yet. ### Phase 4 · Dunning without nagging Build: A retry schedule for hard failures with a bounded number of attempts and one email per attempt. A final notice before access ends. Stop entirely once the subscription is cancelled or paid, so nobody receives a chase after paying. Trap: Continuing to email after a customer has paid or cancelled is the failure people remember and complain about publicly. Check the current state before every send rather than trusting the schedule that queued it, since the state may have changed between queuing and sending. Done when: A failed payment produces one email per attempt on the configured schedule. Paying mid-sequence stops further emails, verified by paying after the second attempt. Cancelling mid-sequence also stops them. The final notice precedes revocation rather than following it. Do not build yet: No cancellation or proration yet. ### Phase 5 · Cancellation, upgrades and the money in between Build: Self-service cancellation taking effect at period end, and immediate cancellation with a prorated credit if you offer it. Plan changes with proration computed in integer minor units. An account page showing the next charge and its date. Trap: Proration arithmetic in floating point produces amounts that disagree with the provider's by a penny, and reconciling that at scale is miserable. Compute in integer minor units and reconcile against the provider's own figure rather than trusting yours. Done when: Cancelling at period end keeps access until that date and then revokes. An upgrade prorates correctly, matching the provider's computed amount to the penny. The account page shows the next charge and date accurately. Cancelling requires no email to you. Do not build yet: Reporting and operations come last. ### Phase 6 · Reporting, refunds and going live Build: Reports of active subscriptions, revenue by period and involuntary churn from failed payments. Handle refunds and disputes by revoking. Reconcile your totals against the provider before enabling live keys. Trap: Involuntary churn from failed payments is usually a larger share of cancellations than people expect, and it is invisible unless you separate it from customers who chose to leave. Report it separately, because the fix is a billing fix rather than a product one. Done when: Active subscription count matches the provider's. Revenue for a period reconciles with the provider's figure. Churn is split between voluntary and involuntary. A refund or dispute revokes entitlement, tested with a real event in test mode. Do not build yet: Nothing after this. ### Out of scope (and why) - The plugin's breadth: variable subscriptions, synchronised renewal dates, multiple currencies per store and the long tail of store configurations it supports. - Tax determination and remittance, which for digital goods depends on the customer's location and is a separate liability. - Migration of an existing subscriber base, which means moving saved payment methods between processors and is a project with its own risk. - The support burden of a store extension, which is what the licence fee actually buys when something breaks during a renewal cycle. ### README must contain - That a renewal can require customer authentication and that this is routine rather than an error - That entitlement is the single gate and no route reads subscription state directly - That the webhook is the only writer of entitlement - The grace period, the dunning schedule and when access actually ends - That proration is computed in integer minor units and reconciled against the provider ===== AGENTS.md ===== # Agent instructions · WooCommerce Subscriptions indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: PHP with WordPress, or any small server, Hosted checkout with saved payment methods and webhooks, Integer minor units, One table consulted by every gate, A transactional provider with a verified domain. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - Checking a subscription's state at each gate spreads billing logic through the application, and the first time a state is added, half the gates are wrong. One entitlement table, one function, and subscriptions write to it rather than being consulted. - Granting on the success redirect hands out access to anyone who can replay a URL, and the customer may close the tab before it loads anyway. The webhook is the truth and the redirect is a thank-you page that must handle arriving before the entitlement exists. - Treating a payment that requires action as a failure and revoking access immediately punishes a customer for their bank's prompt, which they have not seen because they were not present. Expect this state as routine, keep access, ask them, and give them time. - Continuing to email after a customer has paid or cancelled is the failure people remember and complain about publicly. Check the current state before every send rather than trusting the schedule that queued it, since the state may have changed between queuing and sending. - Proration arithmetic in floating point produces amounts that disagree with the provider's by a penny, and reconciling that at scale is miserable. Compute in integer minor units and reconcile against the provider's own figure rather than trusting yours. ===== BUILD_PLAN.md ===== # Build plan · WooCommerce Subscriptions A small set of plans sold through hosted checkout, webhooks as the only writer of entitlement, renewals that handle an authentication requirement by asking the customer rather than failing silently, a dunning sequence with a grace period, and self-service cancellation that does not need you. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Entitlement as the single gate Every access check reads one table, before any money is involved. ### Steps 1. Write one access function Customer and plan to a boolean with a reason. Nothing else decides access anywhere. Files: `src/access.php` 2. Gate every route through it Routes, not templates. A hidden link is not a gate. Files: `src/gates.php` 3. Enumerate gates in a test Walk the routes, request each with and without entitlement, assert both outcomes. Files: `tests/GatesTest.php` ### Done when - [ ] Every gated route is refused without an entitlement, proved by an enumerating test - [ ] An entitlement opens exactly its plan - [ ] Revoking closes access immediately - [ ] No gate reads subscription state directly ### Watch out - Checking a subscription's state at each gate spreads billing logic through the application, and the first time a state is added, half the gates are wrong. One entitlement table, one function, and subscriptions write to it rather than being consulted. ## Phase 2 · First payment and the webhook Someone can subscribe, and only the webhook grants access. ### Steps 1. Create checkout sessions server side Prices from your own table. Never an amount supplied by the client. Files: `src/checkout.php` 2. Make the webhook the only writer Signature verified, event identifiers recorded, entitlements written there and nowhere else. Files: `src/webhook.php` 3. Handle the redirect racing the webhook The thank-you page may load first. Say so gracefully rather than showing no access. Files: `src/return.php` ### Done when - [ ] Entitlement is granted only after the webhook - [ ] A replayed event leaves one entitlement - [ ] A bad signature is rejected - [ ] The redirect handles arriving before the webhook ### Watch out - Granting on the success redirect hands out access to anyone who can replay a URL, and the customer may close the tab before it loads anyway. The webhook is the truth and the redirect is a thank-you page that must handle arriving before the entitlement exists. ## Phase 3 · The renewal that needs the customer A renewal requiring authentication asks the customer instead of failing. ### Steps 1. Handle the requires-action state It is a normal outcome, not an error. Store the authentication link from the invoice. Files: `src/renewals.php` 2. Email the link and grant grace The customer cannot act on something they were not told about. Files: `src/dunning.php` 3. Test with a card that forces authentication Providers offer test cards that always require it. Use one rather than hoping. ### Done when - [ ] A requires-action invoice emails a link and keeps access - [ ] Completing authentication marks the invoice paid - [ ] Access lapses only after the grace period - [ ] The pending state is visible to the customer ### Watch out - Treating a payment that requires action as a failure and revoking access immediately punishes a customer for their bank's prompt, which they have not seen because they were not present. Expect this state as routine, keep access, ask them, and give them time. ## Phase 4 · Dunning without nagging A failed payment is retried and communicated a fixed number of times, then resolved. ### Steps 1. Schedule bounded retries A fixed number of attempts over a fixed window. Unbounded retries are just noise. Files: `src/dunning.php` 2. Check state before sending At send time, not at queue time. This is what prevents chasing a paid customer. Files: `src/dunning.php` 3. Send a final notice before revoking So access ending is not the first the customer hears of it. Files: `src/dunning.php` ### Done when - [ ] One email per attempt on the schedule - [ ] Paying mid-sequence stops further emails - [ ] Cancelling mid-sequence stops them - [ ] A final notice precedes revocation ### Watch out - Continuing to email after a customer has paid or cancelled is the failure people remember and complain about publicly. Check the current state before every send rather than trusting the schedule that queued it, since the state may have changed between queuing and sending. ## Phase 5 · Cancellation, upgrades and the money in between A customer can change or cancel without you, and the amounts are right. ### Steps 1. Offer self-service cancellation Through the provider's portal or your own page. Requiring an email is a dark pattern. Files: `src/account.php` 2. Compute proration in minor units And compare against the provider's figure. A mismatch is a bug rather than rounding. Files: `src/proration.php` 3. Show the next charge honestly Amount and date. It is the question every subscriber has. Files: `src/account.php` ### Done when - [ ] Cancelling at period end keeps access until that date - [ ] Proration matches the provider's amount to the penny - [ ] The account page shows next charge and date - [ ] Cancellation needs no contact with you ### Watch out - Proration arithmetic in floating point produces amounts that disagree with the provider's by a penny, and reconciling that at scale is miserable. Compute in integer minor units and reconcile against the provider's own figure rather than trusting yours. ## Not in this build - The plugin's breadth of subscription types and store configurations. - Tax determination and remittance. - Migrating an existing subscriber base between processors. - Vendor support when something breaks mid-renewal-cycle. ## After v1, if you want it - Annual plans with a discount, which is a second price and a different proration case - A pause rather than cancel option, which retains more customers than any dunning change - A revenue report by cohort, once you have enough months for it to say anything ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. Stay in test mode until reconciliation and the authentication path are both proved. STRIPE_SECRET_KEY=sk_test_... # Required · secret. From the registered endpoint or the listen command. STRIPE_WEBHOOK_SECRET=whsec_... # Required. How long access persists after a failed or pending renewal. A commercial decision, stated in your terms. GRACE_PERIOD_DAYS=7 # Required. Retry attempts before access ends. Bounded, with one email each. DUNNING_ATTEMPTS=4 # Required. On the verified sending domain, since billing email that lands in spam costs customers. EMAIL_FROM=billing@example.com # Required. Where customers manage and cancel without contacting you. ACCOUNT_PORTAL_URL=https://example.com/account
You are building a lean indie version of WooCommerce Subscriptions. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== README.md ===== # WooCommerce Subscriptions · indie build A small set of plans sold through hosted checkout, webhooks as the only writer of entitlement, renewals that handle an authentication requirement by asking the customer rather than failing silently, a dunning sequence with a grace period, and self-service cancellation that does not need you. Estimated effort: **weekend**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | PHP with WordPress, or any small server | the point is the billing state machine rather than the framework; use what the store already runs on | | Payments | Hosted checkout with saved payment methods and webhooks | card details never touch you and the provider handles the authentication flow | | Money | Integer minor units | proration and partial periods produce fractional amounts that floating point rounds wrongly | | Entitlement | One table consulted by every gate | access decided in one place is access you can reason about | | Email | A transactional provider with a verified domain | an authentication request in a spam folder is a cancelled customer | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **A working store or a small server** · free if you already have one - Why: This is billing logic bolted to something that already exists. Building a store first is a different project. - Get it: Use the store you already run, or a small application with user accounts. What matters is that there is something to gate. - Verify: A gated page exists that you can protect - [ ] **A payment provider account with test mode and webhooks** · free in test mode; live billing costs per transaction plus a further fee on subscriptions in most regions - Why: Hosted checkout, saved cards, subscription events and the authentication flow all come from here. - Get it: Sign up, create prices for your plans, take the secret key and the webhook signing secret. - Verify: A triggered subscription event reaches your endpoint and is verified - [ ] **Test cards that force authentication and failure** · free - Why: Phase 3 is the whole point of this build and it cannot be tested with a card that always succeeds. - Get it: Your provider publishes test card numbers including ones that always require authentication and ones that fail on renewal. Find them before Phase 3. - Verify: A renewal produced a requires-action state in test mode - [ ] **A transactional email provider with a verified domain** · free tiers cover this volume - Why: The authentication link and the dunning sequence both depend on delivery, and one in spam is a lost customer. - Get it: Create an account, add the DNS records they specify, take the credentials. - Verify: A test email arrives at three providers and is not in spam - [ ] **Your grace period and dunning schedule** · free - Why: How long a customer keeps access after a failed payment is a commercial decision rather than a technical one, and it should be decided before it is implemented. - Get it: Decide the grace period, the number of retries and when access ends. Write it down and put it in your terms. - Verify: The policy is written and matches the configuration ## Quick start ```sh stripe trigger charge.refunded ``` Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - The plugin's breadth of subscription types and store configurations. - Tax determination and remittance. - Migrating an existing subscriber base between processors. - Vendor support when something breaks mid-renewal-cycle. - Integration with the WooCommerce order, coupon, tax and shipping machinery, so subscriptions are no longer just orders your other plugins understand - Gateway breadth: you are married to one processor instead of the dozen Woo supports - Customer-facing plan switching, pausing, resubscribing and prorated upgrades in your own UI, unless you accept the hosted portal as-is - Admin reporting, renewal calendars and bulk actions over thousands of subscribers - Years of edge case handling for partial renewals, mixed carts, manual renewals and payment method migrations If one of those is essential to you, that is the reason to keep paying for WooCommerce Subscriptions, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · WooCommerce Subscriptions The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me recurring billing for a handful of plans, where a renewal that needs authentication does not silently fail to replace WooCommerce Subscriptions. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero The thing that separates working recurring billing from a demo is the renewal nobody is present for. A saved card charged off session can be declined with an authentication requirement, and in Europe that is a routine category of decline rather than an edge case: strong customer authentication is the rule and the three domain secure prompt is the mechanism. When it happens the payment sits waiting for a customer who is not there. So the design point is not taking the first payment, it is handling the renewal that cannot complete: detect the pending state, email the customer a link to authenticate, keep access during a grace period, and only then lapse. Building this after launch means customers losing access for a bank prompt they never saw. ### Stack (fixed, do not substitute) - Runtime: PHP with WordPress, or any small server · the point is the billing state machine rather than the framework; use what the store already runs on - Payments: Hosted checkout with saved payment methods and webhooks · card details never touch you and the provider handles the authentication flow - Money: Integer minor units · proration and partial periods produce fractional amounts that floating point rounds wrongly - Entitlement: One table consulted by every gate · access decided in one place is access you can reason about - Email: A transactional provider with a verified domain · an authentication request in a spam folder is a cancelled customer ### Data model (create this before Phase 1) - plans(id, name, price_minor, currency, interval, provider_price_id, active) - customers(id, email, provider_customer_id, created_at) - subscriptions(id, customer_id, plan_id, provider_subscription_id, state, current_period_end, cancel_at_period_end, grace_until) - invoices(id, subscription_id, provider_invoice_id, amount_minor, state, attempted_at, authentication_url, paid_at) - entitlements(customer_id, plan_id, granted_at, revoked_at, reason) · the only thing any access check reads - webhook_events(provider_event_id, type, processed_at) · idempotency, because events are redelivered - State is derived from invoices and entitlements rather than set by hand, so the database cannot disagree with itself. ### Phase 1 · Entitlement as the single gate Build: An entitlements table and one function answering whether a customer has access to a plan. Every gated route and template calls it. Grant entitlements by hand for now, so gating is proven before billing complicates it. Trap: Checking a subscription's state at each gate spreads billing logic through the application, and the first time a state is added, half the gates are wrong. One entitlement table, one function, and subscriptions write to it rather than being consulted. Done when: Every gated route is refused without an entitlement, verified by a test enumerating them rather than checking a few by hand. Granting an entitlement opens exactly its plan. Revoking closes access immediately. No gate reads a subscription state directly. Do not build yet: No checkout, renewals or dunning. ### Phase 2 · First payment and the webhook Build: Hosted checkout for each plan, created server side from your own price. A webhook that verifies its signature, records the event identifier for idempotency, and writes the subscription and entitlement. The success redirect grants nothing. Trap: Granting on the success redirect hands out access to anyone who can replay a URL, and the customer may close the tab before it loads anyway. The webhook is the truth and the redirect is a thank-you page that must handle arriving before the entitlement exists. Done when: A test subscription grants entitlement only after the webhook. Replaying the event leaves one entitlement. A bad signature is rejected with a client error. Closing the tab immediately after paying still results in access once the webhook arrives. Do not build yet: No renewals or authentication handling yet. ### Phase 3 · The renewal that needs the customer Build: Handle the invoice event that reports payment requires action. Store the authentication link, email it to the customer, and keep access during a grace period. Complete the subscription when they authenticate, and lapse only after the grace period ends. Trap: Treating a payment that requires action as a failure and revoking access immediately punishes a customer for their bank's prompt, which they have not seen because they were not present. Expect this state as routine, keep access, ask them, and give them time. Done when: An invoice in the requires-action state emails an authentication link and does not revoke access. Completing the authentication marks the invoice paid and clears the grace period. Access persists through the grace period and lapses after it. The pending state is visible in the customer's account page. Do not build yet: No dunning sequence or cancellation yet. ### Phase 4 · Dunning without nagging Build: A retry schedule for hard failures with a bounded number of attempts and one email per attempt. A final notice before access ends. Stop entirely once the subscription is cancelled or paid, so nobody receives a chase after paying. Trap: Continuing to email after a customer has paid or cancelled is the failure people remember and complain about publicly. Check the current state before every send rather than trusting the schedule that queued it, since the state may have changed between queuing and sending. Done when: A failed payment produces one email per attempt on the configured schedule. Paying mid-sequence stops further emails, verified by paying after the second attempt. Cancelling mid-sequence also stops them. The final notice precedes revocation rather than following it. Do not build yet: No cancellation or proration yet. ### Phase 5 · Cancellation, upgrades and the money in between Build: Self-service cancellation taking effect at period end, and immediate cancellation with a prorated credit if you offer it. Plan changes with proration computed in integer minor units. An account page showing the next charge and its date. Trap: Proration arithmetic in floating point produces amounts that disagree with the provider's by a penny, and reconciling that at scale is miserable. Compute in integer minor units and reconcile against the provider's own figure rather than trusting yours. Done when: Cancelling at period end keeps access until that date and then revokes. An upgrade prorates correctly, matching the provider's computed amount to the penny. The account page shows the next charge and date accurately. Cancelling requires no email to you. Do not build yet: Reporting and operations come last. ### Phase 6 · Reporting, refunds and going live Build: Reports of active subscriptions, revenue by period and involuntary churn from failed payments. Handle refunds and disputes by revoking. Reconcile your totals against the provider before enabling live keys. Trap: Involuntary churn from failed payments is usually a larger share of cancellations than people expect, and it is invisible unless you separate it from customers who chose to leave. Report it separately, because the fix is a billing fix rather than a product one. Done when: Active subscription count matches the provider's. Revenue for a period reconciles with the provider's figure. Churn is split between voluntary and involuntary. A refund or dispute revokes entitlement, tested with a real event in test mode. Do not build yet: Nothing after this. ### Out of scope (and why) - The plugin's breadth: variable subscriptions, synchronised renewal dates, multiple currencies per store and the long tail of store configurations it supports. - Tax determination and remittance, which for digital goods depends on the customer's location and is a separate liability. - Migration of an existing subscriber base, which means moving saved payment methods between processors and is a project with its own risk. - The support burden of a store extension, which is what the licence fee actually buys when something breaks during a renewal cycle. ### README must contain - That a renewal can require customer authentication and that this is routine rather than an error - That entitlement is the single gate and no route reads subscription state directly - That the webhook is the only writer of entitlement - The grace period, the dunning schedule and when access actually ends - That proration is computed in integer minor units and reconciled against the provider ===== AGENTS.md ===== # Agent instructions · WooCommerce Subscriptions indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: PHP with WordPress, or any small server, Hosted checkout with saved payment methods and webhooks, Integer minor units, One table consulted by every gate, A transactional provider with a verified domain. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - Checking a subscription's state at each gate spreads billing logic through the application, and the first time a state is added, half the gates are wrong. One entitlement table, one function, and subscriptions write to it rather than being consulted. - Granting on the success redirect hands out access to anyone who can replay a URL, and the customer may close the tab before it loads anyway. The webhook is the truth and the redirect is a thank-you page that must handle arriving before the entitlement exists. - Treating a payment that requires action as a failure and revoking access immediately punishes a customer for their bank's prompt, which they have not seen because they were not present. Expect this state as routine, keep access, ask them, and give them time. - Continuing to email after a customer has paid or cancelled is the failure people remember and complain about publicly. Check the current state before every send rather than trusting the schedule that queued it, since the state may have changed between queuing and sending. - Proration arithmetic in floating point produces amounts that disagree with the provider's by a penny, and reconciling that at scale is miserable. Compute in integer minor units and reconcile against the provider's own figure rather than trusting yours. ===== BUILD_PLAN.md ===== # Build plan · WooCommerce Subscriptions A small set of plans sold through hosted checkout, webhooks as the only writer of entitlement, renewals that handle an authentication requirement by asking the customer rather than failing silently, a dunning sequence with a grace period, and self-service cancellation that does not need you. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Entitlement as the single gate Every access check reads one table, before any money is involved. ### Steps 1. Write one access function Customer and plan to a boolean with a reason. Nothing else decides access anywhere. Files: `src/access.php` 2. Gate every route through it Routes, not templates. A hidden link is not a gate. Files: `src/gates.php` 3. Enumerate gates in a test Walk the routes, request each with and without entitlement, assert both outcomes. Files: `tests/GatesTest.php` ### Done when - [ ] Every gated route is refused without an entitlement, proved by an enumerating test - [ ] An entitlement opens exactly its plan - [ ] Revoking closes access immediately - [ ] No gate reads subscription state directly ### Watch out - Checking a subscription's state at each gate spreads billing logic through the application, and the first time a state is added, half the gates are wrong. One entitlement table, one function, and subscriptions write to it rather than being consulted. ## Phase 2 · First payment and the webhook Someone can subscribe, and only the webhook grants access. ### Steps 1. Create checkout sessions server side Prices from your own table. Never an amount supplied by the client. Files: `src/checkout.php` 2. Make the webhook the only writer Signature verified, event identifiers recorded, entitlements written there and nowhere else. Files: `src/webhook.php` 3. Handle the redirect racing the webhook The thank-you page may load first. Say so gracefully rather than showing no access. Files: `src/return.php` ### Done when - [ ] Entitlement is granted only after the webhook - [ ] A replayed event leaves one entitlement - [ ] A bad signature is rejected - [ ] The redirect handles arriving before the webhook ### Watch out - Granting on the success redirect hands out access to anyone who can replay a URL, and the customer may close the tab before it loads anyway. The webhook is the truth and the redirect is a thank-you page that must handle arriving before the entitlement exists. ## Phase 3 · The renewal that needs the customer A renewal requiring authentication asks the customer instead of failing. ### Steps 1. Handle the requires-action state It is a normal outcome, not an error. Store the authentication link from the invoice. Files: `src/renewals.php` 2. Email the link and grant grace The customer cannot act on something they were not told about. Files: `src/dunning.php` 3. Test with a card that forces authentication Providers offer test cards that always require it. Use one rather than hoping. ### Done when - [ ] A requires-action invoice emails a link and keeps access - [ ] Completing authentication marks the invoice paid - [ ] Access lapses only after the grace period - [ ] The pending state is visible to the customer ### Watch out - Treating a payment that requires action as a failure and revoking access immediately punishes a customer for their bank's prompt, which they have not seen because they were not present. Expect this state as routine, keep access, ask them, and give them time. ## Phase 4 · Dunning without nagging A failed payment is retried and communicated a fixed number of times, then resolved. ### Steps 1. Schedule bounded retries A fixed number of attempts over a fixed window. Unbounded retries are just noise. Files: `src/dunning.php` 2. Check state before sending At send time, not at queue time. This is what prevents chasing a paid customer. Files: `src/dunning.php` 3. Send a final notice before revoking So access ending is not the first the customer hears of it. Files: `src/dunning.php` ### Done when - [ ] One email per attempt on the schedule - [ ] Paying mid-sequence stops further emails - [ ] Cancelling mid-sequence stops them - [ ] A final notice precedes revocation ### Watch out - Continuing to email after a customer has paid or cancelled is the failure people remember and complain about publicly. Check the current state before every send rather than trusting the schedule that queued it, since the state may have changed between queuing and sending. ## Phase 5 · Cancellation, upgrades and the money in between A customer can change or cancel without you, and the amounts are right. ### Steps 1. Offer self-service cancellation Through the provider's portal or your own page. Requiring an email is a dark pattern. Files: `src/account.php` 2. Compute proration in minor units And compare against the provider's figure. A mismatch is a bug rather than rounding. Files: `src/proration.php` 3. Show the next charge honestly Amount and date. It is the question every subscriber has. Files: `src/account.php` ### Done when - [ ] Cancelling at period end keeps access until that date - [ ] Proration matches the provider's amount to the penny - [ ] The account page shows next charge and date - [ ] Cancellation needs no contact with you ### Watch out - Proration arithmetic in floating point produces amounts that disagree with the provider's by a penny, and reconciling that at scale is miserable. Compute in integer minor units and reconcile against the provider's own figure rather than trusting yours. ## Not in this build - The plugin's breadth of subscription types and store configurations. - Tax determination and remittance. - Migrating an existing subscriber base between processors. - Vendor support when something breaks mid-renewal-cycle. ## After v1, if you want it - Annual plans with a discount, which is a second price and a different proration case - A pause rather than cancel option, which retains more customers than any dunning change - A revenue report by cohort, once you have enough months for it to say anything ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. Stay in test mode until reconciliation and the authentication path are both proved. STRIPE_SECRET_KEY=sk_test_... # Required · secret. From the registered endpoint or the listen command. STRIPE_WEBHOOK_SECRET=whsec_... # Required. How long access persists after a failed or pending renewal. A commercial decision, stated in your terms. GRACE_PERIOD_DAYS=7 # Required. Retry attempts before access ends. Bounded, with one email each. DUNNING_ATTEMPTS=4 # Required. On the verified sending domain, since billing email that lands in spam costs customers. EMAIL_FROM=billing@example.com # Required. Where customers manage and cancel without contacting you. ACCOUNT_PORTAL_URL=https://example.com/account
You are building a production product version of WooCommerce Subscriptions. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== PRODUCT.md ===== # WooCommerce Subscriptions · product brief ## Problem The actual hard part of recurring billing lives at the payment processor, not in this plugin: Stripe Billing already does tokenization, renewal attempts, SCA, proration and dunning. So an agent can absolutely wire a small self-hosted checkout plus webhook listener plus entitlement table in a weekend, and for one product with two or three plans that is genuinely enough. What you cannot one-shot is the part that makes this extension worth a license: living inside WooCommerce so that orders, coupons, taxes, shipping, emails, reports and forty other plugins all understand a subscription. If you already run a real Woo store, replacing this means rebuilding the glue, not the billing. If you just want money to arrive monthly for something you built, you never needed WordPress in the first place. ## Product outcome Recurring billing where the renewal that needs a bank prompt is handled by asking the customer rather than cutting them off, where access is decided in one place, and where your numbers reconcile with the provider's. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - Stripe account in live mode - A publicly reachable HTTPS host for webhook delivery - Node 20 and SQLite - Stripe API keys and webhook secret in .env ## Explicit non-goals for v1 - The plugin's breadth of subscription types and store configurations. - Tax determination and remittance. - Migrating an existing subscriber base between processors. - Vendor support when something breaks mid-renewal-cycle. - Integration with the WooCommerce order, coupon, tax and shipping machinery, so subscriptions are no longer just orders your other plugins understand - Gateway breadth: you are married to one processor instead of the dozen Woo supports - Customer-facing plan switching, pausing, resubscribing and prorated upgrades in your own UI, unless you accept the hosted portal as-is - Admin reporting, renewal calendars and bulk actions over thousands of subscribers - Years of edge case handling for partial renewals, mixed carts, manual renewals and payment method migrations ## Success criteria - The enumerating gate test passes for every gated route - A replayed webhook does not double-grant - A requires-action renewal is confirmed to email a link and keep access - Paying mid-dunning is confirmed to stop further emails - Active count and period revenue reconcile with the provider ===== BRIEF.md ===== # Build brief · WooCommerce Subscriptions The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me recurring billing for a handful of plans, where a renewal that needs authentication does not silently fail to replace WooCommerce Subscriptions. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero The thing that separates working recurring billing from a demo is the renewal nobody is present for. A saved card charged off session can be declined with an authentication requirement, and in Europe that is a routine category of decline rather than an edge case: strong customer authentication is the rule and the three domain secure prompt is the mechanism. When it happens the payment sits waiting for a customer who is not there. So the design point is not taking the first payment, it is handling the renewal that cannot complete: detect the pending state, email the customer a link to authenticate, keep access during a grace period, and only then lapse. Building this after launch means customers losing access for a bank prompt they never saw. ### Stack (fixed, do not substitute) - Runtime: PHP with WordPress, or any small server · the point is the billing state machine rather than the framework; use what the store already runs on - Payments: Hosted checkout with saved payment methods and webhooks · card details never touch you and the provider handles the authentication flow - Money: Integer minor units · proration and partial periods produce fractional amounts that floating point rounds wrongly - Entitlement: One table consulted by every gate · access decided in one place is access you can reason about - Email: A transactional provider with a verified domain · an authentication request in a spam folder is a cancelled customer ### Data model (create this before Phase 1) - plans(id, name, price_minor, currency, interval, provider_price_id, active) - customers(id, email, provider_customer_id, created_at) - subscriptions(id, customer_id, plan_id, provider_subscription_id, state, current_period_end, cancel_at_period_end, grace_until) - invoices(id, subscription_id, provider_invoice_id, amount_minor, state, attempted_at, authentication_url, paid_at) - entitlements(customer_id, plan_id, granted_at, revoked_at, reason) · the only thing any access check reads - webhook_events(provider_event_id, type, processed_at) · idempotency, because events are redelivered - State is derived from invoices and entitlements rather than set by hand, so the database cannot disagree with itself. ### Phase 1 · Entitlement as the single gate Build: An entitlements table and one function answering whether a customer has access to a plan. Every gated route and template calls it. Grant entitlements by hand for now, so gating is proven before billing complicates it. Trap: Checking a subscription's state at each gate spreads billing logic through the application, and the first time a state is added, half the gates are wrong. One entitlement table, one function, and subscriptions write to it rather than being consulted. Done when: Every gated route is refused without an entitlement, verified by a test enumerating them rather than checking a few by hand. Granting an entitlement opens exactly its plan. Revoking closes access immediately. No gate reads a subscription state directly. Do not build yet: No checkout, renewals or dunning. ### Phase 2 · First payment and the webhook Build: Hosted checkout for each plan, created server side from your own price. A webhook that verifies its signature, records the event identifier for idempotency, and writes the subscription and entitlement. The success redirect grants nothing. Trap: Granting on the success redirect hands out access to anyone who can replay a URL, and the customer may close the tab before it loads anyway. The webhook is the truth and the redirect is a thank-you page that must handle arriving before the entitlement exists. Done when: A test subscription grants entitlement only after the webhook. Replaying the event leaves one entitlement. A bad signature is rejected with a client error. Closing the tab immediately after paying still results in access once the webhook arrives. Do not build yet: No renewals or authentication handling yet. ### Phase 3 · The renewal that needs the customer Build: Handle the invoice event that reports payment requires action. Store the authentication link, email it to the customer, and keep access during a grace period. Complete the subscription when they authenticate, and lapse only after the grace period ends. Trap: Treating a payment that requires action as a failure and revoking access immediately punishes a customer for their bank's prompt, which they have not seen because they were not present. Expect this state as routine, keep access, ask them, and give them time. Done when: An invoice in the requires-action state emails an authentication link and does not revoke access. Completing the authentication marks the invoice paid and clears the grace period. Access persists through the grace period and lapses after it. The pending state is visible in the customer's account page. Do not build yet: No dunning sequence or cancellation yet. ### Phase 4 · Dunning without nagging Build: A retry schedule for hard failures with a bounded number of attempts and one email per attempt. A final notice before access ends. Stop entirely once the subscription is cancelled or paid, so nobody receives a chase after paying. Trap: Continuing to email after a customer has paid or cancelled is the failure people remember and complain about publicly. Check the current state before every send rather than trusting the schedule that queued it, since the state may have changed between queuing and sending. Done when: A failed payment produces one email per attempt on the configured schedule. Paying mid-sequence stops further emails, verified by paying after the second attempt. Cancelling mid-sequence also stops them. The final notice precedes revocation rather than following it. Do not build yet: No cancellation or proration yet. ### Phase 5 · Cancellation, upgrades and the money in between Build: Self-service cancellation taking effect at period end, and immediate cancellation with a prorated credit if you offer it. Plan changes with proration computed in integer minor units. An account page showing the next charge and its date. Trap: Proration arithmetic in floating point produces amounts that disagree with the provider's by a penny, and reconciling that at scale is miserable. Compute in integer minor units and reconcile against the provider's own figure rather than trusting yours. Done when: Cancelling at period end keeps access until that date and then revokes. An upgrade prorates correctly, matching the provider's computed amount to the penny. The account page shows the next charge and date accurately. Cancelling requires no email to you. Do not build yet: Reporting and operations come last. ### Phase 6 · Reporting, refunds and going live Build: Reports of active subscriptions, revenue by period and involuntary churn from failed payments. Handle refunds and disputes by revoking. Reconcile your totals against the provider before enabling live keys. Trap: Involuntary churn from failed payments is usually a larger share of cancellations than people expect, and it is invisible unless you separate it from customers who chose to leave. Report it separately, because the fix is a billing fix rather than a product one. Done when: Active subscription count matches the provider's. Revenue for a period reconciles with the provider's figure. Churn is split between voluntary and involuntary. A refund or dispute revokes entitlement, tested with a real event in test mode. Do not build yet: Nothing after this. ### Out of scope (and why) - The plugin's breadth: variable subscriptions, synchronised renewal dates, multiple currencies per store and the long tail of store configurations it supports. - Tax determination and remittance, which for digital goods depends on the customer's location and is a separate liability. - Migration of an existing subscriber base, which means moving saved payment methods between processors and is a project with its own risk. - The support burden of a store extension, which is what the licence fee actually buys when something breaks during a renewal cycle. ### README must contain - That a renewal can require customer authentication and that this is routine rather than an error - That entitlement is the single gate and no route reads subscription state directly - That the webhook is the only writer of entitlement - The grace period, the dunning schedule and when access actually ends - That proration is computed in integer minor units and reconciled against the provider ===== ARCHITECTURE.md ===== # Architecture · WooCommerce Subscriptions ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | PHP with WordPress, or any small server | the point is the billing state machine rather than the framework; use what the store already runs on | | Payments | Hosted checkout with saved payment methods and webhooks | card details never touch you and the provider handles the authentication flow | | Money | Integer minor units | proration and partial periods produce fractional amounts that floating point rounds wrongly | | Entitlement | One table consulted by every gate | access decided in one place is access you can reason about | | Email | A transactional provider with a verified domain | an authentication request in a spam folder is a cancelled customer | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Entitlement | Every access decision. | One table and one function. A second place deciding access is the bug, whatever else changes. | | Webhook | Writing billing state. | Another provider changes the event names and signature check. The webhook-only rule survives. | | Renewals | Pending authentication and grace. | Not swappable for treating it as a failure. It is the reason this build is more than a checkout. | | Dunning | Bounded communication. | Any scheduler, provided state is checked at send time rather than at queue time. | | Reports | Reconciliation and churn cause. | Format follows your accounting. Splitting churn by cause is the analytical decision that matters. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `STRIPE_SECRET_KEY` · required, secret · Stay in test mode until reconciliation and the authentication path are both proved. - `STRIPE_WEBHOOK_SECRET` · required, secret · From the registered endpoint or the listen command. - `GRACE_PERIOD_DAYS` · required · How long access persists after a failed or pending renewal. A commercial decision, stated in your terms. - `DUNNING_ATTEMPTS` · required · Retry attempts before access ends. Bounded, with one email each. - `EMAIL_FROM` · required · On the verified sending domain, since billing email that lands in spam costs customers. - `ACCOUNT_PORTAL_URL` · required · Where customers manage and cancel without contacting you. ## Production baseline - Security: least privilege, input validation at every boundary, secret redaction in logs, rate limits on abuse-prone paths, no invented security primitives. - Data: explicit schema and migrations, transactional writes where integrity matters, backup and restore procedures that have been exercised. - Integrations: adapters around third-party providers, idempotent webhook or job processing, bounded retries, timeouts. - Observability: structured logs with request or operation ids, an error-tracking hook, and health and readiness checks where a server exists. - Quality: unit tests for domain rules, integration tests at module boundaries, one end-to-end test of the critical path. ## Decision records For each dependency in the stack table, keep a short note: why it was chosen, its failure mode, and how it is replaced. Do not add infrastructure until a requirement in `PRODUCT.md` justifies it. ===== AGENTS.md ===== # Agent instructions · WooCommerce Subscriptions product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: PHP with WordPress, or any small server, Hosted checkout with saved payment methods and webhooks, Integer minor units, One table consulted by every gate, A transactional provider with a verified domain. - Implement milestone by milestone from `MILESTONES.md`; keep each change reviewable and leave the application runnable at every commit. - Treat authentication, payments, encryption, imports, webhooks and destructive actions as high-risk boundaries when present. - Never invent cryptography or silently weaken a requirement to make a check pass. - Put every external service behind an interface with a deterministic fake for tests. - Add migrations and rollback or recovery notes for every persistent data change. - Log useful operational context without credentials, tokens, passwords or personal data. - Update documentation and run every check before completing a milestone. ## Known traps - Checking a subscription's state at each gate spreads billing logic through the application, and the first time a state is added, half the gates are wrong. One entitlement table, one function, and subscriptions write to it rather than being consulted. - Granting on the success redirect hands out access to anyone who can replay a URL, and the customer may close the tab before it loads anyway. The webhook is the truth and the redirect is a thank-you page that must handle arriving before the entitlement exists. - Treating a payment that requires action as a failure and revoking access immediately punishes a customer for their bank's prompt, which they have not seen because they were not present. Expect this state as routine, keep access, ask them, and give them time. - Continuing to email after a customer has paid or cancelled is the failure people remember and complain about publicly. Check the current state before every send rather than trusting the schedule that queued it, since the state may have changed between queuing and sending. - Proration arithmetic in floating point produces amounts that disagree with the provider's by a penny, and reconciling that at scale is miserable. Compute in integer minor units and reconcile against the provider's own figure rather than trusting yours. - Involuntary churn from failed payments is usually a larger share of cancellations than people expect, and it is invisible unless you separate it from customers who chose to leave. Report it separately, because the fix is a billing fix rather than a product one. ===== MILESTONES.md ===== # Delivery milestones · WooCommerce Subscriptions Estimated effort: **weekend** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · Entitlement as the single gate Every access check reads one table, before any money is involved. ### Steps 1. Write one access function Customer and plan to a boolean with a reason. Nothing else decides access anywhere. Files: `src/access.php` 2. Gate every route through it Routes, not templates. A hidden link is not a gate. Files: `src/gates.php` 3. Enumerate gates in a test Walk the routes, request each with and without entitlement, assert both outcomes. Files: `tests/GatesTest.php` ### Done when - [ ] Every gated route is refused without an entitlement, proved by an enumerating test - [ ] An entitlement opens exactly its plan - [ ] Revoking closes access immediately - [ ] No gate reads subscription state directly ### Watch out - Checking a subscription's state at each gate spreads billing logic through the application, and the first time a state is added, half the gates are wrong. One entitlement table, one function, and subscriptions write to it rather than being consulted. ## M2 · First payment and the webhook Someone can subscribe, and only the webhook grants access. ### Steps 1. Create checkout sessions server side Prices from your own table. Never an amount supplied by the client. Files: `src/checkout.php` 2. Make the webhook the only writer Signature verified, event identifiers recorded, entitlements written there and nowhere else. Files: `src/webhook.php` 3. Handle the redirect racing the webhook The thank-you page may load first. Say so gracefully rather than showing no access. Files: `src/return.php` ### Done when - [ ] Entitlement is granted only after the webhook - [ ] A replayed event leaves one entitlement - [ ] A bad signature is rejected - [ ] The redirect handles arriving before the webhook ### Watch out - Granting on the success redirect hands out access to anyone who can replay a URL, and the customer may close the tab before it loads anyway. The webhook is the truth and the redirect is a thank-you page that must handle arriving before the entitlement exists. ## M3 · The renewal that needs the customer A renewal requiring authentication asks the customer instead of failing. ### Steps 1. Handle the requires-action state It is a normal outcome, not an error. Store the authentication link from the invoice. Files: `src/renewals.php` 2. Email the link and grant grace The customer cannot act on something they were not told about. Files: `src/dunning.php` 3. Test with a card that forces authentication Providers offer test cards that always require it. Use one rather than hoping. ### Done when - [ ] A requires-action invoice emails a link and keeps access - [ ] Completing authentication marks the invoice paid - [ ] Access lapses only after the grace period - [ ] The pending state is visible to the customer ### Watch out - Treating a payment that requires action as a failure and revoking access immediately punishes a customer for their bank's prompt, which they have not seen because they were not present. Expect this state as routine, keep access, ask them, and give them time. ## M4 · Dunning without nagging A failed payment is retried and communicated a fixed number of times, then resolved. ### Steps 1. Schedule bounded retries A fixed number of attempts over a fixed window. Unbounded retries are just noise. Files: `src/dunning.php` 2. Check state before sending At send time, not at queue time. This is what prevents chasing a paid customer. Files: `src/dunning.php` 3. Send a final notice before revoking So access ending is not the first the customer hears of it. Files: `src/dunning.php` ### Done when - [ ] One email per attempt on the schedule - [ ] Paying mid-sequence stops further emails - [ ] Cancelling mid-sequence stops them - [ ] A final notice precedes revocation ### Watch out - Continuing to email after a customer has paid or cancelled is the failure people remember and complain about publicly. Check the current state before every send rather than trusting the schedule that queued it, since the state may have changed between queuing and sending. ## M5 · Cancellation, upgrades and the money in between A customer can change or cancel without you, and the amounts are right. ### Steps 1. Offer self-service cancellation Through the provider's portal or your own page. Requiring an email is a dark pattern. Files: `src/account.php` 2. Compute proration in minor units And compare against the provider's figure. A mismatch is a bug rather than rounding. Files: `src/proration.php` 3. Show the next charge honestly Amount and date. It is the question every subscriber has. Files: `src/account.php` ### Done when - [ ] Cancelling at period end keeps access until that date - [ ] Proration matches the provider's amount to the penny - [ ] The account page shows next charge and date - [ ] Cancellation needs no contact with you ### Watch out - Proration arithmetic in floating point produces amounts that disagree with the provider's by a penny, and reconciling that at scale is miserable. Compute in integer minor units and reconcile against the provider's own figure rather than trusting yours. ## M6 · Reporting, refunds and going live (production only) You can answer who is subscribed and what happened to the money. ### Steps 1. Reconcile against the provider Counts and revenue for the same window. A gap is a bug, not a rounding difference. Files: `src/reports.php` 2. Split churn by cause Voluntary and involuntary. They have different fixes and mixing them hides the cheaper one. Files: `src/reports.php` 3. Handle refunds and disputes Revoke entitlement. Trigger both in test mode before going live. Files: `src/webhook.php` ```sh stripe trigger charge.refunded ``` ### Done when - [ ] Active count matches the provider - [ ] Period revenue reconciles with the provider - [ ] Churn is split by cause - [ ] A refund revokes entitlement ### Watch out - Involuntary churn from failed payments is usually a larger share of cancellations than people expect, and it is invisible unless you separate it from customers who chose to leave. Report it separately, because the fix is a billing fix rather than a product one. ===== OPERATIONS.md ===== # Operations · WooCommerce Subscriptions ## Backup The database holds subscriptions and entitlements, which must be reconstructable against the provider. The provider is the second copy of the billing truth, which makes reconciliation both a check and a recovery path. ## Restore Restore, then reconcile every active subscription against the provider before serving. A restored entitlement table that disagrees with the provider will either give away access or take it wrongly. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Webhook failures, invoices sitting in a pending authentication state, and the involuntary churn rate. A pending invoice nobody chased is a customer about to leave for a reason you could have fixed. ## Incident checklist If customers lose access unexpectedly, check for pending authentication states before anything else, since that is the failure this build exists to prevent. If numbers disagree with the provider, the provider is right and your event handling missed something. 1. Contain the issue without destroying evidence or user data. 2. Record the timeline and affected scope. 3. Rotate exposed secrets and revoke compromised sessions or credentials. 4. Restore from a verified backup when needed. 5. Document the root cause, the remediation and the regression test. ## Release gate - [ ] The enumerating gate test passes for every gated route - [ ] A replayed webhook does not double-grant - [ ] A requires-action renewal is confirmed to email a link and keep access - [ ] Paying mid-dunning is confirmed to stop further emails - [ ] Active count and period revenue reconcile with the provider ## Launch constraint Do not market omitted WooCommerce Subscriptions capabilities as implemented. The non-goals in `PRODUCT.md` remain user-visible limitations until they are deliberately delivered. ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. Stay in test mode until reconciliation and the authentication path are both proved. STRIPE_SECRET_KEY=sk_test_... # Required · secret. From the registered endpoint or the listen command. STRIPE_WEBHOOK_SECRET=whsec_... # Required. How long access persists after a failed or pending renewal. A commercial decision, stated in your terms. GRACE_PERIOD_DAYS=7 # Required. Retry attempts before access ends. Bounded, with one email each. DUNNING_ATTEMPTS=4 # Required. On the verified sending domain, since billing email that lands in spam costs customers. EMAIL_FROM=billing@example.com # Required. Where customers manage and cancel without contacting you. ACCOUNT_PORTAL_URL=https://example.com/account
# WooCommerce Subscriptions · indie build A small set of plans sold through hosted checkout, webhooks as the only writer of entitlement, renewals that handle an authentication requirement by asking the customer rather than failing silently, a dunning sequence with a grace period, and self-service cancellation that does not need you. Estimated effort: **weekend**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | PHP with WordPress, or any small server | the point is the billing state machine rather than the framework; use what the store already runs on | | Payments | Hosted checkout with saved payment methods and webhooks | card details never touch you and the provider handles the authentication flow | | Money | Integer minor units | proration and partial periods produce fractional amounts that floating point rounds wrongly | | Entitlement | One table consulted by every gate | access decided in one place is access you can reason about | | Email | A transactional provider with a verified domain | an authentication request in a spam folder is a cancelled customer | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **A working store or a small server** · free if you already have one - Why: This is billing logic bolted to something that already exists. Building a store first is a different project. - Get it: Use the store you already run, or a small application with user accounts. What matters is that there is something to gate. - Verify: A gated page exists that you can protect - [ ] **A payment provider account with test mode and webhooks** · free in test mode; live billing costs per transaction plus a further fee on subscriptions in most regions - Why: Hosted checkout, saved cards, subscription events and the authentication flow all come from here. - Get it: Sign up, create prices for your plans, take the secret key and the webhook signing secret. - Verify: A triggered subscription event reaches your endpoint and is verified - [ ] **Test cards that force authentication and failure** · free - Why: Phase 3 is the whole point of this build and it cannot be tested with a card that always succeeds. - Get it: Your provider publishes test card numbers including ones that always require authentication and ones that fail on renewal. Find them before Phase 3. - Verify: A renewal produced a requires-action state in test mode - [ ] **A transactional email provider with a verified domain** · free tiers cover this volume - Why: The authentication link and the dunning sequence both depend on delivery, and one in spam is a lost customer. - Get it: Create an account, add the DNS records they specify, take the credentials. - Verify: A test email arrives at three providers and is not in spam - [ ] **Your grace period and dunning schedule** · free - Why: How long a customer keeps access after a failed payment is a commercial decision rather than a technical one, and it should be decided before it is implemented. - Get it: Decide the grace period, the number of retries and when access ends. Write it down and put it in your terms. - Verify: The policy is written and matches the configuration ## Quick start ```sh stripe trigger charge.refunded ``` Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - The plugin's breadth of subscription types and store configurations. - Tax determination and remittance. - Migrating an existing subscriber base between processors. - Vendor support when something breaks mid-renewal-cycle. - Integration with the WooCommerce order, coupon, tax and shipping machinery, so subscriptions are no longer just orders your other plugins understand - Gateway breadth: you are married to one processor instead of the dozen Woo supports - Customer-facing plan switching, pausing, resubscribing and prorated upgrades in your own UI, unless you accept the hosted portal as-is - Admin reporting, renewal calendars and bulk actions over thousands of subscribers - Years of edge case handling for partial renewals, mixed carts, manual renewals and payment method migrations If one of those is essential to you, that is the reason to keep paying for WooCommerce Subscriptions, and the README should say so rather than pretend.
# Build brief · WooCommerce Subscriptions The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me recurring billing for a handful of plans, where a renewal that needs authentication does not silently fail to replace WooCommerce Subscriptions. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero The thing that separates working recurring billing from a demo is the renewal nobody is present for. A saved card charged off session can be declined with an authentication requirement, and in Europe that is a routine category of decline rather than an edge case: strong customer authentication is the rule and the three domain secure prompt is the mechanism. When it happens the payment sits waiting for a customer who is not there. So the design point is not taking the first payment, it is handling the renewal that cannot complete: detect the pending state, email the customer a link to authenticate, keep access during a grace period, and only then lapse. Building this after launch means customers losing access for a bank prompt they never saw. ### Stack (fixed, do not substitute) - Runtime: PHP with WordPress, or any small server · the point is the billing state machine rather than the framework; use what the store already runs on - Payments: Hosted checkout with saved payment methods and webhooks · card details never touch you and the provider handles the authentication flow - Money: Integer minor units · proration and partial periods produce fractional amounts that floating point rounds wrongly - Entitlement: One table consulted by every gate · access decided in one place is access you can reason about - Email: A transactional provider with a verified domain · an authentication request in a spam folder is a cancelled customer ### Data model (create this before Phase 1) - plans(id, name, price_minor, currency, interval, provider_price_id, active) - customers(id, email, provider_customer_id, created_at) - subscriptions(id, customer_id, plan_id, provider_subscription_id, state, current_period_end, cancel_at_period_end, grace_until) - invoices(id, subscription_id, provider_invoice_id, amount_minor, state, attempted_at, authentication_url, paid_at) - entitlements(customer_id, plan_id, granted_at, revoked_at, reason) · the only thing any access check reads - webhook_events(provider_event_id, type, processed_at) · idempotency, because events are redelivered - State is derived from invoices and entitlements rather than set by hand, so the database cannot disagree with itself. ### Phase 1 · Entitlement as the single gate Build: An entitlements table and one function answering whether a customer has access to a plan. Every gated route and template calls it. Grant entitlements by hand for now, so gating is proven before billing complicates it. Trap: Checking a subscription's state at each gate spreads billing logic through the application, and the first time a state is added, half the gates are wrong. One entitlement table, one function, and subscriptions write to it rather than being consulted. Done when: Every gated route is refused without an entitlement, verified by a test enumerating them rather than checking a few by hand. Granting an entitlement opens exactly its plan. Revoking closes access immediately. No gate reads a subscription state directly. Do not build yet: No checkout, renewals or dunning. ### Phase 2 · First payment and the webhook Build: Hosted checkout for each plan, created server side from your own price. A webhook that verifies its signature, records the event identifier for idempotency, and writes the subscription and entitlement. The success redirect grants nothing. Trap: Granting on the success redirect hands out access to anyone who can replay a URL, and the customer may close the tab before it loads anyway. The webhook is the truth and the redirect is a thank-you page that must handle arriving before the entitlement exists. Done when: A test subscription grants entitlement only after the webhook. Replaying the event leaves one entitlement. A bad signature is rejected with a client error. Closing the tab immediately after paying still results in access once the webhook arrives. Do not build yet: No renewals or authentication handling yet. ### Phase 3 · The renewal that needs the customer Build: Handle the invoice event that reports payment requires action. Store the authentication link, email it to the customer, and keep access during a grace period. Complete the subscription when they authenticate, and lapse only after the grace period ends. Trap: Treating a payment that requires action as a failure and revoking access immediately punishes a customer for their bank's prompt, which they have not seen because they were not present. Expect this state as routine, keep access, ask them, and give them time. Done when: An invoice in the requires-action state emails an authentication link and does not revoke access. Completing the authentication marks the invoice paid and clears the grace period. Access persists through the grace period and lapses after it. The pending state is visible in the customer's account page. Do not build yet: No dunning sequence or cancellation yet. ### Phase 4 · Dunning without nagging Build: A retry schedule for hard failures with a bounded number of attempts and one email per attempt. A final notice before access ends. Stop entirely once the subscription is cancelled or paid, so nobody receives a chase after paying. Trap: Continuing to email after a customer has paid or cancelled is the failure people remember and complain about publicly. Check the current state before every send rather than trusting the schedule that queued it, since the state may have changed between queuing and sending. Done when: A failed payment produces one email per attempt on the configured schedule. Paying mid-sequence stops further emails, verified by paying after the second attempt. Cancelling mid-sequence also stops them. The final notice precedes revocation rather than following it. Do not build yet: No cancellation or proration yet. ### Phase 5 · Cancellation, upgrades and the money in between Build: Self-service cancellation taking effect at period end, and immediate cancellation with a prorated credit if you offer it. Plan changes with proration computed in integer minor units. An account page showing the next charge and its date. Trap: Proration arithmetic in floating point produces amounts that disagree with the provider's by a penny, and reconciling that at scale is miserable. Compute in integer minor units and reconcile against the provider's own figure rather than trusting yours. Done when: Cancelling at period end keeps access until that date and then revokes. An upgrade prorates correctly, matching the provider's computed amount to the penny. The account page shows the next charge and date accurately. Cancelling requires no email to you. Do not build yet: Reporting and operations come last. ### Phase 6 · Reporting, refunds and going live Build: Reports of active subscriptions, revenue by period and involuntary churn from failed payments. Handle refunds and disputes by revoking. Reconcile your totals against the provider before enabling live keys. Trap: Involuntary churn from failed payments is usually a larger share of cancellations than people expect, and it is invisible unless you separate it from customers who chose to leave. Report it separately, because the fix is a billing fix rather than a product one. Done when: Active subscription count matches the provider's. Revenue for a period reconciles with the provider's figure. Churn is split between voluntary and involuntary. A refund or dispute revokes entitlement, tested with a real event in test mode. Do not build yet: Nothing after this. ### Out of scope (and why) - The plugin's breadth: variable subscriptions, synchronised renewal dates, multiple currencies per store and the long tail of store configurations it supports. - Tax determination and remittance, which for digital goods depends on the customer's location and is a separate liability. - Migration of an existing subscriber base, which means moving saved payment methods between processors and is a project with its own risk. - The support burden of a store extension, which is what the licence fee actually buys when something breaks during a renewal cycle. ### README must contain - That a renewal can require customer authentication and that this is routine rather than an error - That entitlement is the single gate and no route reads subscription state directly - That the webhook is the only writer of entitlement - The grace period, the dunning schedule and when access actually ends - That proration is computed in integer minor units and reconciled against the provider
# Agent instructions · WooCommerce Subscriptions indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: PHP with WordPress, or any small server, Hosted checkout with saved payment methods and webhooks, Integer minor units, One table consulted by every gate, A transactional provider with a verified domain. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - Checking a subscription's state at each gate spreads billing logic through the application, and the first time a state is added, half the gates are wrong. One entitlement table, one function, and subscriptions write to it rather than being consulted. - Granting on the success redirect hands out access to anyone who can replay a URL, and the customer may close the tab before it loads anyway. The webhook is the truth and the redirect is a thank-you page that must handle arriving before the entitlement exists. - Treating a payment that requires action as a failure and revoking access immediately punishes a customer for their bank's prompt, which they have not seen because they were not present. Expect this state as routine, keep access, ask them, and give them time. - Continuing to email after a customer has paid or cancelled is the failure people remember and complain about publicly. Check the current state before every send rather than trusting the schedule that queued it, since the state may have changed between queuing and sending. - Proration arithmetic in floating point produces amounts that disagree with the provider's by a penny, and reconciling that at scale is miserable. Compute in integer minor units and reconcile against the provider's own figure rather than trusting yours.
# Build plan · WooCommerce Subscriptions A small set of plans sold through hosted checkout, webhooks as the only writer of entitlement, renewals that handle an authentication requirement by asking the customer rather than failing silently, a dunning sequence with a grace period, and self-service cancellation that does not need you. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Entitlement as the single gate Every access check reads one table, before any money is involved. ### Steps 1. Write one access function Customer and plan to a boolean with a reason. Nothing else decides access anywhere. Files: `src/access.php` 2. Gate every route through it Routes, not templates. A hidden link is not a gate. Files: `src/gates.php` 3. Enumerate gates in a test Walk the routes, request each with and without entitlement, assert both outcomes. Files: `tests/GatesTest.php` ### Done when - [ ] Every gated route is refused without an entitlement, proved by an enumerating test - [ ] An entitlement opens exactly its plan - [ ] Revoking closes access immediately - [ ] No gate reads subscription state directly ### Watch out - Checking a subscription's state at each gate spreads billing logic through the application, and the first time a state is added, half the gates are wrong. One entitlement table, one function, and subscriptions write to it rather than being consulted. ## Phase 2 · First payment and the webhook Someone can subscribe, and only the webhook grants access. ### Steps 1. Create checkout sessions server side Prices from your own table. Never an amount supplied by the client. Files: `src/checkout.php` 2. Make the webhook the only writer Signature verified, event identifiers recorded, entitlements written there and nowhere else. Files: `src/webhook.php` 3. Handle the redirect racing the webhook The thank-you page may load first. Say so gracefully rather than showing no access. Files: `src/return.php` ### Done when - [ ] Entitlement is granted only after the webhook - [ ] A replayed event leaves one entitlement - [ ] A bad signature is rejected - [ ] The redirect handles arriving before the webhook ### Watch out - Granting on the success redirect hands out access to anyone who can replay a URL, and the customer may close the tab before it loads anyway. The webhook is the truth and the redirect is a thank-you page that must handle arriving before the entitlement exists. ## Phase 3 · The renewal that needs the customer A renewal requiring authentication asks the customer instead of failing. ### Steps 1. Handle the requires-action state It is a normal outcome, not an error. Store the authentication link from the invoice. Files: `src/renewals.php` 2. Email the link and grant grace The customer cannot act on something they were not told about. Files: `src/dunning.php` 3. Test with a card that forces authentication Providers offer test cards that always require it. Use one rather than hoping. ### Done when - [ ] A requires-action invoice emails a link and keeps access - [ ] Completing authentication marks the invoice paid - [ ] Access lapses only after the grace period - [ ] The pending state is visible to the customer ### Watch out - Treating a payment that requires action as a failure and revoking access immediately punishes a customer for their bank's prompt, which they have not seen because they were not present. Expect this state as routine, keep access, ask them, and give them time. ## Phase 4 · Dunning without nagging A failed payment is retried and communicated a fixed number of times, then resolved. ### Steps 1. Schedule bounded retries A fixed number of attempts over a fixed window. Unbounded retries are just noise. Files: `src/dunning.php` 2. Check state before sending At send time, not at queue time. This is what prevents chasing a paid customer. Files: `src/dunning.php` 3. Send a final notice before revoking So access ending is not the first the customer hears of it. Files: `src/dunning.php` ### Done when - [ ] One email per attempt on the schedule - [ ] Paying mid-sequence stops further emails - [ ] Cancelling mid-sequence stops them - [ ] A final notice precedes revocation ### Watch out - Continuing to email after a customer has paid or cancelled is the failure people remember and complain about publicly. Check the current state before every send rather than trusting the schedule that queued it, since the state may have changed between queuing and sending. ## Phase 5 · Cancellation, upgrades and the money in between A customer can change or cancel without you, and the amounts are right. ### Steps 1. Offer self-service cancellation Through the provider's portal or your own page. Requiring an email is a dark pattern. Files: `src/account.php` 2. Compute proration in minor units And compare against the provider's figure. A mismatch is a bug rather than rounding. Files: `src/proration.php` 3. Show the next charge honestly Amount and date. It is the question every subscriber has. Files: `src/account.php` ### Done when - [ ] Cancelling at period end keeps access until that date - [ ] Proration matches the provider's amount to the penny - [ ] The account page shows next charge and date - [ ] Cancellation needs no contact with you ### Watch out - Proration arithmetic in floating point produces amounts that disagree with the provider's by a penny, and reconciling that at scale is miserable. Compute in integer minor units and reconcile against the provider's own figure rather than trusting yours. ## Not in this build - The plugin's breadth of subscription types and store configurations. - Tax determination and remittance. - Migrating an existing subscriber base between processors. - Vendor support when something breaks mid-renewal-cycle. ## After v1, if you want it - Annual plans with a discount, which is a second price and a different proration case - A pause rather than cancel option, which retains more customers than any dunning change - A revenue report by cohort, once you have enough months for it to say anything
# Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. Stay in test mode until reconciliation and the authentication path are both proved. STRIPE_SECRET_KEY=sk_test_... # Required · secret. From the registered endpoint or the listen command. STRIPE_WEBHOOK_SECRET=whsec_... # Required. How long access persists after a failed or pending renewal. A commercial decision, stated in your terms. GRACE_PERIOD_DAYS=7 # Required. Retry attempts before access ends. Bounded, with one email each. DUNNING_ATTEMPTS=4 # Required. On the verified sending domain, since billing email that lands in spam costs customers. EMAIL_FROM=billing@example.com # Required. Where customers manage and cancel without contacting you. ACCOUNT_PORTAL_URL=https://example.com/account
# WooCommerce Subscriptions · product brief ## Problem The actual hard part of recurring billing lives at the payment processor, not in this plugin: Stripe Billing already does tokenization, renewal attempts, SCA, proration and dunning. So an agent can absolutely wire a small self-hosted checkout plus webhook listener plus entitlement table in a weekend, and for one product with two or three plans that is genuinely enough. What you cannot one-shot is the part that makes this extension worth a license: living inside WooCommerce so that orders, coupons, taxes, shipping, emails, reports and forty other plugins all understand a subscription. If you already run a real Woo store, replacing this means rebuilding the glue, not the billing. If you just want money to arrive monthly for something you built, you never needed WordPress in the first place. ## Product outcome Recurring billing where the renewal that needs a bank prompt is handled by asking the customer rather than cutting them off, where access is decided in one place, and where your numbers reconcile with the provider's. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - Stripe account in live mode - A publicly reachable HTTPS host for webhook delivery - Node 20 and SQLite - Stripe API keys and webhook secret in .env ## Explicit non-goals for v1 - The plugin's breadth of subscription types and store configurations. - Tax determination and remittance. - Migrating an existing subscriber base between processors. - Vendor support when something breaks mid-renewal-cycle. - Integration with the WooCommerce order, coupon, tax and shipping machinery, so subscriptions are no longer just orders your other plugins understand - Gateway breadth: you are married to one processor instead of the dozen Woo supports - Customer-facing plan switching, pausing, resubscribing and prorated upgrades in your own UI, unless you accept the hosted portal as-is - Admin reporting, renewal calendars and bulk actions over thousands of subscribers - Years of edge case handling for partial renewals, mixed carts, manual renewals and payment method migrations ## Success criteria - The enumerating gate test passes for every gated route - A replayed webhook does not double-grant - A requires-action renewal is confirmed to email a link and keep access - Paying mid-dunning is confirmed to stop further emails - Active count and period revenue reconcile with the provider
# Build brief · WooCommerce Subscriptions The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me recurring billing for a handful of plans, where a renewal that needs authentication does not silently fail to replace WooCommerce Subscriptions. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero The thing that separates working recurring billing from a demo is the renewal nobody is present for. A saved card charged off session can be declined with an authentication requirement, and in Europe that is a routine category of decline rather than an edge case: strong customer authentication is the rule and the three domain secure prompt is the mechanism. When it happens the payment sits waiting for a customer who is not there. So the design point is not taking the first payment, it is handling the renewal that cannot complete: detect the pending state, email the customer a link to authenticate, keep access during a grace period, and only then lapse. Building this after launch means customers losing access for a bank prompt they never saw. ### Stack (fixed, do not substitute) - Runtime: PHP with WordPress, or any small server · the point is the billing state machine rather than the framework; use what the store already runs on - Payments: Hosted checkout with saved payment methods and webhooks · card details never touch you and the provider handles the authentication flow - Money: Integer minor units · proration and partial periods produce fractional amounts that floating point rounds wrongly - Entitlement: One table consulted by every gate · access decided in one place is access you can reason about - Email: A transactional provider with a verified domain · an authentication request in a spam folder is a cancelled customer ### Data model (create this before Phase 1) - plans(id, name, price_minor, currency, interval, provider_price_id, active) - customers(id, email, provider_customer_id, created_at) - subscriptions(id, customer_id, plan_id, provider_subscription_id, state, current_period_end, cancel_at_period_end, grace_until) - invoices(id, subscription_id, provider_invoice_id, amount_minor, state, attempted_at, authentication_url, paid_at) - entitlements(customer_id, plan_id, granted_at, revoked_at, reason) · the only thing any access check reads - webhook_events(provider_event_id, type, processed_at) · idempotency, because events are redelivered - State is derived from invoices and entitlements rather than set by hand, so the database cannot disagree with itself. ### Phase 1 · Entitlement as the single gate Build: An entitlements table and one function answering whether a customer has access to a plan. Every gated route and template calls it. Grant entitlements by hand for now, so gating is proven before billing complicates it. Trap: Checking a subscription's state at each gate spreads billing logic through the application, and the first time a state is added, half the gates are wrong. One entitlement table, one function, and subscriptions write to it rather than being consulted. Done when: Every gated route is refused without an entitlement, verified by a test enumerating them rather than checking a few by hand. Granting an entitlement opens exactly its plan. Revoking closes access immediately. No gate reads a subscription state directly. Do not build yet: No checkout, renewals or dunning. ### Phase 2 · First payment and the webhook Build: Hosted checkout for each plan, created server side from your own price. A webhook that verifies its signature, records the event identifier for idempotency, and writes the subscription and entitlement. The success redirect grants nothing. Trap: Granting on the success redirect hands out access to anyone who can replay a URL, and the customer may close the tab before it loads anyway. The webhook is the truth and the redirect is a thank-you page that must handle arriving before the entitlement exists. Done when: A test subscription grants entitlement only after the webhook. Replaying the event leaves one entitlement. A bad signature is rejected with a client error. Closing the tab immediately after paying still results in access once the webhook arrives. Do not build yet: No renewals or authentication handling yet. ### Phase 3 · The renewal that needs the customer Build: Handle the invoice event that reports payment requires action. Store the authentication link, email it to the customer, and keep access during a grace period. Complete the subscription when they authenticate, and lapse only after the grace period ends. Trap: Treating a payment that requires action as a failure and revoking access immediately punishes a customer for their bank's prompt, which they have not seen because they were not present. Expect this state as routine, keep access, ask them, and give them time. Done when: An invoice in the requires-action state emails an authentication link and does not revoke access. Completing the authentication marks the invoice paid and clears the grace period. Access persists through the grace period and lapses after it. The pending state is visible in the customer's account page. Do not build yet: No dunning sequence or cancellation yet. ### Phase 4 · Dunning without nagging Build: A retry schedule for hard failures with a bounded number of attempts and one email per attempt. A final notice before access ends. Stop entirely once the subscription is cancelled or paid, so nobody receives a chase after paying. Trap: Continuing to email after a customer has paid or cancelled is the failure people remember and complain about publicly. Check the current state before every send rather than trusting the schedule that queued it, since the state may have changed between queuing and sending. Done when: A failed payment produces one email per attempt on the configured schedule. Paying mid-sequence stops further emails, verified by paying after the second attempt. Cancelling mid-sequence also stops them. The final notice precedes revocation rather than following it. Do not build yet: No cancellation or proration yet. ### Phase 5 · Cancellation, upgrades and the money in between Build: Self-service cancellation taking effect at period end, and immediate cancellation with a prorated credit if you offer it. Plan changes with proration computed in integer minor units. An account page showing the next charge and its date. Trap: Proration arithmetic in floating point produces amounts that disagree with the provider's by a penny, and reconciling that at scale is miserable. Compute in integer minor units and reconcile against the provider's own figure rather than trusting yours. Done when: Cancelling at period end keeps access until that date and then revokes. An upgrade prorates correctly, matching the provider's computed amount to the penny. The account page shows the next charge and date accurately. Cancelling requires no email to you. Do not build yet: Reporting and operations come last. ### Phase 6 · Reporting, refunds and going live Build: Reports of active subscriptions, revenue by period and involuntary churn from failed payments. Handle refunds and disputes by revoking. Reconcile your totals against the provider before enabling live keys. Trap: Involuntary churn from failed payments is usually a larger share of cancellations than people expect, and it is invisible unless you separate it from customers who chose to leave. Report it separately, because the fix is a billing fix rather than a product one. Done when: Active subscription count matches the provider's. Revenue for a period reconciles with the provider's figure. Churn is split between voluntary and involuntary. A refund or dispute revokes entitlement, tested with a real event in test mode. Do not build yet: Nothing after this. ### Out of scope (and why) - The plugin's breadth: variable subscriptions, synchronised renewal dates, multiple currencies per store and the long tail of store configurations it supports. - Tax determination and remittance, which for digital goods depends on the customer's location and is a separate liability. - Migration of an existing subscriber base, which means moving saved payment methods between processors and is a project with its own risk. - The support burden of a store extension, which is what the licence fee actually buys when something breaks during a renewal cycle. ### README must contain - That a renewal can require customer authentication and that this is routine rather than an error - That entitlement is the single gate and no route reads subscription state directly - That the webhook is the only writer of entitlement - The grace period, the dunning schedule and when access actually ends - That proration is computed in integer minor units and reconciled against the provider
# Architecture · WooCommerce Subscriptions ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | PHP with WordPress, or any small server | the point is the billing state machine rather than the framework; use what the store already runs on | | Payments | Hosted checkout with saved payment methods and webhooks | card details never touch you and the provider handles the authentication flow | | Money | Integer minor units | proration and partial periods produce fractional amounts that floating point rounds wrongly | | Entitlement | One table consulted by every gate | access decided in one place is access you can reason about | | Email | A transactional provider with a verified domain | an authentication request in a spam folder is a cancelled customer | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Entitlement | Every access decision. | One table and one function. A second place deciding access is the bug, whatever else changes. | | Webhook | Writing billing state. | Another provider changes the event names and signature check. The webhook-only rule survives. | | Renewals | Pending authentication and grace. | Not swappable for treating it as a failure. It is the reason this build is more than a checkout. | | Dunning | Bounded communication. | Any scheduler, provided state is checked at send time rather than at queue time. | | Reports | Reconciliation and churn cause. | Format follows your accounting. Splitting churn by cause is the analytical decision that matters. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `STRIPE_SECRET_KEY` · required, secret · Stay in test mode until reconciliation and the authentication path are both proved. - `STRIPE_WEBHOOK_SECRET` · required, secret · From the registered endpoint or the listen command. - `GRACE_PERIOD_DAYS` · required · How long access persists after a failed or pending renewal. A commercial decision, stated in your terms. - `DUNNING_ATTEMPTS` · required · Retry attempts before access ends. Bounded, with one email each. - `EMAIL_FROM` · required · On the verified sending domain, since billing email that lands in spam costs customers. - `ACCOUNT_PORTAL_URL` · required · Where customers manage and cancel without contacting you. ## Production baseline - Security: least privilege, input validation at every boundary, secret redaction in logs, rate limits on abuse-prone paths, no invented security primitives. - Data: explicit schema and migrations, transactional writes where integrity matters, backup and restore procedures that have been exercised. - Integrations: adapters around third-party providers, idempotent webhook or job processing, bounded retries, timeouts. - Observability: structured logs with request or operation ids, an error-tracking hook, and health and readiness checks where a server exists. - Quality: unit tests for domain rules, integration tests at module boundaries, one end-to-end test of the critical path. ## Decision records For each dependency in the stack table, keep a short note: why it was chosen, its failure mode, and how it is replaced. Do not add infrastructure until a requirement in `PRODUCT.md` justifies it.
# Agent instructions · WooCommerce Subscriptions product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: PHP with WordPress, or any small server, Hosted checkout with saved payment methods and webhooks, Integer minor units, One table consulted by every gate, A transactional provider with a verified domain. - Implement milestone by milestone from `MILESTONES.md`; keep each change reviewable and leave the application runnable at every commit. - Treat authentication, payments, encryption, imports, webhooks and destructive actions as high-risk boundaries when present. - Never invent cryptography or silently weaken a requirement to make a check pass. - Put every external service behind an interface with a deterministic fake for tests. - Add migrations and rollback or recovery notes for every persistent data change. - Log useful operational context without credentials, tokens, passwords or personal data. - Update documentation and run every check before completing a milestone. ## Known traps - Checking a subscription's state at each gate spreads billing logic through the application, and the first time a state is added, half the gates are wrong. One entitlement table, one function, and subscriptions write to it rather than being consulted. - Granting on the success redirect hands out access to anyone who can replay a URL, and the customer may close the tab before it loads anyway. The webhook is the truth and the redirect is a thank-you page that must handle arriving before the entitlement exists. - Treating a payment that requires action as a failure and revoking access immediately punishes a customer for their bank's prompt, which they have not seen because they were not present. Expect this state as routine, keep access, ask them, and give them time. - Continuing to email after a customer has paid or cancelled is the failure people remember and complain about publicly. Check the current state before every send rather than trusting the schedule that queued it, since the state may have changed between queuing and sending. - Proration arithmetic in floating point produces amounts that disagree with the provider's by a penny, and reconciling that at scale is miserable. Compute in integer minor units and reconcile against the provider's own figure rather than trusting yours. - Involuntary churn from failed payments is usually a larger share of cancellations than people expect, and it is invisible unless you separate it from customers who chose to leave. Report it separately, because the fix is a billing fix rather than a product one.
# Delivery milestones · WooCommerce Subscriptions Estimated effort: **weekend** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · Entitlement as the single gate Every access check reads one table, before any money is involved. ### Steps 1. Write one access function Customer and plan to a boolean with a reason. Nothing else decides access anywhere. Files: `src/access.php` 2. Gate every route through it Routes, not templates. A hidden link is not a gate. Files: `src/gates.php` 3. Enumerate gates in a test Walk the routes, request each with and without entitlement, assert both outcomes. Files: `tests/GatesTest.php` ### Done when - [ ] Every gated route is refused without an entitlement, proved by an enumerating test - [ ] An entitlement opens exactly its plan - [ ] Revoking closes access immediately - [ ] No gate reads subscription state directly ### Watch out - Checking a subscription's state at each gate spreads billing logic through the application, and the first time a state is added, half the gates are wrong. One entitlement table, one function, and subscriptions write to it rather than being consulted. ## M2 · First payment and the webhook Someone can subscribe, and only the webhook grants access. ### Steps 1. Create checkout sessions server side Prices from your own table. Never an amount supplied by the client. Files: `src/checkout.php` 2. Make the webhook the only writer Signature verified, event identifiers recorded, entitlements written there and nowhere else. Files: `src/webhook.php` 3. Handle the redirect racing the webhook The thank-you page may load first. Say so gracefully rather than showing no access. Files: `src/return.php` ### Done when - [ ] Entitlement is granted only after the webhook - [ ] A replayed event leaves one entitlement - [ ] A bad signature is rejected - [ ] The redirect handles arriving before the webhook ### Watch out - Granting on the success redirect hands out access to anyone who can replay a URL, and the customer may close the tab before it loads anyway. The webhook is the truth and the redirect is a thank-you page that must handle arriving before the entitlement exists. ## M3 · The renewal that needs the customer A renewal requiring authentication asks the customer instead of failing. ### Steps 1. Handle the requires-action state It is a normal outcome, not an error. Store the authentication link from the invoice. Files: `src/renewals.php` 2. Email the link and grant grace The customer cannot act on something they were not told about. Files: `src/dunning.php` 3. Test with a card that forces authentication Providers offer test cards that always require it. Use one rather than hoping. ### Done when - [ ] A requires-action invoice emails a link and keeps access - [ ] Completing authentication marks the invoice paid - [ ] Access lapses only after the grace period - [ ] The pending state is visible to the customer ### Watch out - Treating a payment that requires action as a failure and revoking access immediately punishes a customer for their bank's prompt, which they have not seen because they were not present. Expect this state as routine, keep access, ask them, and give them time. ## M4 · Dunning without nagging A failed payment is retried and communicated a fixed number of times, then resolved. ### Steps 1. Schedule bounded retries A fixed number of attempts over a fixed window. Unbounded retries are just noise. Files: `src/dunning.php` 2. Check state before sending At send time, not at queue time. This is what prevents chasing a paid customer. Files: `src/dunning.php` 3. Send a final notice before revoking So access ending is not the first the customer hears of it. Files: `src/dunning.php` ### Done when - [ ] One email per attempt on the schedule - [ ] Paying mid-sequence stops further emails - [ ] Cancelling mid-sequence stops them - [ ] A final notice precedes revocation ### Watch out - Continuing to email after a customer has paid or cancelled is the failure people remember and complain about publicly. Check the current state before every send rather than trusting the schedule that queued it, since the state may have changed between queuing and sending. ## M5 · Cancellation, upgrades and the money in between A customer can change or cancel without you, and the amounts are right. ### Steps 1. Offer self-service cancellation Through the provider's portal or your own page. Requiring an email is a dark pattern. Files: `src/account.php` 2. Compute proration in minor units And compare against the provider's figure. A mismatch is a bug rather than rounding. Files: `src/proration.php` 3. Show the next charge honestly Amount and date. It is the question every subscriber has. Files: `src/account.php` ### Done when - [ ] Cancelling at period end keeps access until that date - [ ] Proration matches the provider's amount to the penny - [ ] The account page shows next charge and date - [ ] Cancellation needs no contact with you ### Watch out - Proration arithmetic in floating point produces amounts that disagree with the provider's by a penny, and reconciling that at scale is miserable. Compute in integer minor units and reconcile against the provider's own figure rather than trusting yours. ## M6 · Reporting, refunds and going live (production only) You can answer who is subscribed and what happened to the money. ### Steps 1. Reconcile against the provider Counts and revenue for the same window. A gap is a bug, not a rounding difference. Files: `src/reports.php` 2. Split churn by cause Voluntary and involuntary. They have different fixes and mixing them hides the cheaper one. Files: `src/reports.php` 3. Handle refunds and disputes Revoke entitlement. Trigger both in test mode before going live. Files: `src/webhook.php` ```sh stripe trigger charge.refunded ``` ### Done when - [ ] Active count matches the provider - [ ] Period revenue reconciles with the provider - [ ] Churn is split by cause - [ ] A refund revokes entitlement ### Watch out - Involuntary churn from failed payments is usually a larger share of cancellations than people expect, and it is invisible unless you separate it from customers who chose to leave. Report it separately, because the fix is a billing fix rather than a product one.
# Operations · WooCommerce Subscriptions ## Backup The database holds subscriptions and entitlements, which must be reconstructable against the provider. The provider is the second copy of the billing truth, which makes reconciliation both a check and a recovery path. ## Restore Restore, then reconcile every active subscription against the provider before serving. A restored entitlement table that disagrees with the provider will either give away access or take it wrongly. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Webhook failures, invoices sitting in a pending authentication state, and the involuntary churn rate. A pending invoice nobody chased is a customer about to leave for a reason you could have fixed. ## Incident checklist If customers lose access unexpectedly, check for pending authentication states before anything else, since that is the failure this build exists to prevent. If numbers disagree with the provider, the provider is right and your event handling missed something. 1. Contain the issue without destroying evidence or user data. 2. Record the timeline and affected scope. 3. Rotate exposed secrets and revoke compromised sessions or credentials. 4. Restore from a verified backup when needed. 5. Document the root cause, the remediation and the regression test. ## Release gate - [ ] The enumerating gate test passes for every gated route - [ ] A replayed webhook does not double-grant - [ ] A requires-action renewal is confirmed to email a link and keep access - [ ] Paying mid-dunning is confirmed to stop further emails - [ ] Active count and period revenue reconcile with the provider ## Launch constraint Do not market omitted WooCommerce Subscriptions capabilities as implemented. The non-goals in `PRODUCT.md` remain user-visible limitations until they are deliberately delivered.
# Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. Stay in test mode until reconciliation and the authentication path are both proved. STRIPE_SECRET_KEY=sk_test_... # Required · secret. From the registered endpoint or the listen command. STRIPE_WEBHOOK_SECRET=whsec_... # Required. How long access persists after a failed or pending renewal. A commercial decision, stated in your terms. GRACE_PERIOD_DAYS=7 # Required. Retry attempts before access ends. Bounded, with one email each. DUNNING_ATTEMPTS=4 # Required. On the verified sending domain, since billing email that lands in spam costs customers. EMAIL_FROM=billing@example.com # Required. Where customers manage and cancel without contacting you. ACCOUNT_PORTAL_URL=https://example.com/account
$ choose a build depth, inspect the files, then open the complete pack in your agent
Because their store is already WooCommerce, and in that world this extension is the thing every other plugin, theme and gateway expects to be present. Paying once a year to have renewals, dunning emails, subscriber lists and coupon rules behave like normal Woo orders is cheaper than maintaining a parallel billing system that has to be kept in sync with the store forever. People building something new from scratch have a much easier time: point Stripe or Paddle at it and skip all of this.
xIntegration with the WooCommerce order, coupon, tax and shipping machinery, so subscriptions are no longer just orders your other plugins understand
xGateway breadth: you are married to one processor instead of the dozen Woo supports
xCustomer-facing plan switching, pausing, resubscribing and prorated upgrades in your own UI, unless you accept the hosted portal as-is
xAdmin reporting, renewal calendars and bulk actions over thousands of subscribers
xYears of edge case handling for partial renewals, mixed carts, manual renewals and payment method migrations
Nothing worth pointing at. That's why the prompt exists.
Vibecode WooCommerce Subscriptions
Kinda. The core of WooCommerce Subscriptions is buildable in a weekend with the prompt on this page, but there are real gaps: Integration with the WooCommerce order, coupon, tax and shipping machinery, so subscriptions are no longer just orders your other plugins understand, Gateway breadth: you are married to one processor instead of the dozen Woo supports. Read the honest list above before committing.
How much does WooCommerce Subscriptions cost?
WooCommerce Subscriptions costs about $23.25/month (Single site licence, billed annually, checked 2026-08-18), which is $279 per year.
What do I lose by replacing WooCommerce Subscriptions?
Honestly: Integration with the WooCommerce order, coupon, tax and shipping machinery, so subscriptions are no longer just orders your other plugins understand; Gateway breadth: you are married to one processor instead of the dozen Woo supports; Customer-facing plan switching, pausing, resubscribing and prorated upgrades in your own UI, unless you accept the hosted portal as-is; Admin reporting, renewal calendars and bulk actions over thousands of subscribers; Years of edge case handling for partial renewals, mixed carts, manual renewals and payment method migrations. If any of those are load-bearing for you, keep paying.
Is there an open-source alternative to WooCommerce Subscriptions?
No mature open-source alternative worth pointing at, which is exactly why the one-shot prompt on this page exists.